diff --git a/.claude/worktrees/agent-a84e8640/.github/workflows/linux.yml b/.claude/worktrees/agent-a84e8640/.github/workflows/linux.yml new file mode 100644 index 0000000..62461db --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/.github/workflows/linux.yml @@ -0,0 +1,42 @@ +name: Linux + +on: + push: + branches: [main] + pull_request: + +jobs: + build-and-test: + name: Swift ${{ matrix.swift }} on Linux + runs-on: ubuntu-latest + strategy: + matrix: + swift: ["6.2"] + container: swift:${{ matrix.swift }} + + steps: + - uses: actions/checkout@v4 + + - name: Build DaggerheartModels + run: swift build --target DaggerheartModels + + - name: Build validate-dhpack + run: swift build --target validate-dhpack + + - name: Test DaggerheartModels + run: swift test --filter DaggerheartModelsTests + + swift-format: + name: swift-format lint + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + container: swift:6.2 + + steps: + - uses: actions/checkout@v4 + + - name: Mark workspace as safe for git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Lint + run: git ls-files -z '*.swift' | xargs -0 swift-format lint --strict --parallel diff --git a/.claude/worktrees/agent-a84e8640/.gitignore b/.claude/worktrees/agent-a84e8640/.gitignore new file mode 100644 index 0000000..5984a98 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/.gitignore @@ -0,0 +1,37 @@ +# Xcode +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +*.xcworkspace/contents.xcworkspacedata +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Swift Package Manager +.build/ +.swiftpm/ +*.resolved + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +# Test results +TestResults.xcresult + +# Claude Code (local settings — machine-specific) +.claude/settings.local.json diff --git a/.claude/worktrees/agent-a84e8640/.spi.yml b/.claude/worktrees/agent-a84e8640/.spi.yml new file mode 100644 index 0000000..b2bce0a --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [DaggerheartModels, DaggerheartKit] diff --git a/.claude/worktrees/agent-a84e8640/.swift-format b/.claude/worktrees/agent-a84e8640/.swift-format new file mode 100644 index 0000000..efe5aa4 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/.swift-format @@ -0,0 +1,75 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "indentation" : { + "spaces" : 2 + }, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineBreakBetweenDeclarationAttributes" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "reflowMultilineStringLiterals" : "never", + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "AvoidRetroactiveConformances" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyLinesOpeningClosingBraces" : false, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + }, + "spacesAroundRangeFormationOperators" : false, + "spacesBeforeEndOfLineComments" : 2, + "tabWidth" : 8, + "version" : 1 +} diff --git a/.claude/worktrees/agent-a84e8640/CLAUDE.md b/.claude/worktrees/agent-a84e8640/CLAUDE.md new file mode 100644 index 0000000..f5e599e --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/CLAUDE.md @@ -0,0 +1,145 @@ +# DaggerheartModels — Claude Code Context + +Swift Package containing the Daggerheart model layer. +Two targets: `DaggerheartModels` (Linux-safe value types) and `DaggerheartKit` +(Apple-platform `@Observable` stores). A `validate-dhpack` CLI validates content packs. + +--- + +## Package structure + +``` +DaggerheartModels/ +├── Sources/ +│ ├── DaggerheartModels/ # Foundation-only; no Apple-only frameworks +│ ├── DaggerheartKit/ +│ │ └── Resources/ # adversaries.json, environments.json (SRD data) +│ └── validate-dhpack/ +├── Tests/ +│ ├── DaggerheartModelsTests/ # Linux-compatible; runs in Linux CI +│ │ └── Fixtures/ # adversaries.json, environments.json, sample-homebrew.dhpack +│ └── DaggerheartKitTests/ # Apple-platform only +├── schemas/ # dhpack.schema.json (JSON Schema for .dhpack files) +├── Package.swift +├── .swift-format # Formatting rules (matches gwillish/encounter) +└── Scripts/format.sh # Format all tracked Swift files +``` + +--- + +## Build configuration + +- **Swift:** 6.2, `.swiftLanguageMode(.v6)` +- **Platforms:** iOS 17, macOS 14, tvOS 17, watchOS 10 (DaggerheartKit); unrestricted (DaggerheartModels) +- **Swift settings active on all targets:** + - `.enableUpcomingFeature("MemberImportVisibility")` — members from transitive + dependencies are not visible without an explicit import +- **DaggerheartKit additionally:** + - `.defaultIsolation(MainActor.self)` — all non-isolated code defaults to `@MainActor` + +### Implication of MemberImportVisibility + +Any file that uses types from `DaggerheartModels` (even through `DaggerheartKit`) +must explicitly `import DaggerheartModels`. This applies to test files too. + +--- + +## Building + +```bash +# Build both libraries +swift build + +# Build a specific target +swift build --target DaggerheartModels +swift build --target DaggerheartKit +swift build --target validate-dhpack +``` + +--- + +## Testing + +```bash +# Run all tests +swift test + +# Linux-safe model tests only (also what the Linux CI runs) +swift test --filter DaggerheartModelsTests + +# Kit tests (Apple-platform only) +swift test --filter DaggerheartKitTests +``` + +Tests use **Swift Testing** (`import Testing`), not XCTest. + +`DaggerheartModelsTests` must stay Linux-compatible: no `@Observable`, no +`Compendium`, no `EncounterStore`. Anything using Apple-platform-only types +belongs in `DaggerheartKitTests`. + +--- + +## Formatting + +Run before every commit: + +```bash +./Scripts/format.sh +``` + +The script formats and lints all tracked Swift files: + +```bash +git ls-files -z '*.swift' | xargs -0 swift-format format --parallel --in-place +git ls-files -z '*.swift' | xargs -0 swift-format lint --strict --parallel +``` + +--- + +## CI + +`.github/workflows/linux.yml`: + +- **Build + test on Linux** — Swift 6.1 and 6.2 on `ubuntu-latest`; runs + `swift build --target DaggerheartModels`, `swift build --target validate-dhpack`, + and `swift test --filter DaggerheartModelsTests` +- **swift-format lint** — runs on pull requests only; container `swift:6.2` + +`DaggerheartKitTests` is intentionally excluded from Linux CI because +`DaggerheartKit` depends on `Observation`, which requires Apple platforms. + +--- + +## Adding new model types + +1. Add the `.swift` file to `Sources/DaggerheartModels/` if the type is + Foundation-only, or to `Sources/DaggerheartKit/` if it needs `@Observable` + or Apple-only frameworks. +2. Make it `public`. +3. Add `Codable` conformance if it will appear in `.dhpack` or JSON files. +4. Write tests in `DaggerheartModelsTests` (for model types) or `DaggerheartKitTests` + (for observable stores). Follow red-green TDD. +5. Run `./Scripts/format.sh` before committing. + +--- + +## Key conventions + +- **No force-unwrap** in any source or test file. +- **Daggerheart naming:** use game terms as-is (`hp`, `stress`, `fear`, `hope`, + `difficulty`, `thresholds`) — do not rename to generic equivalents. +- **Bundle resources:** `DaggerheartKit` resources (SRD JSON) are accessed via + `Bundle.module`, which is internal — it cannot appear in a `public` default + argument. Use `bundle: Bundle? = nil` and resolve as `bundle ?? .module` inside + the function body. +- **Test fixtures:** place JSON fixtures in `Tests/DaggerheartModelsTests/Fixtures/` + and declare them as `.copy("Fixtures")` in Package.swift. Access via + `Bundle.module.url(forResource:withExtension:subdirectory:)` with + `subdirectory: "Fixtures"`. + +--- + +## Git + +- **Never commit on behalf of the user.** Wait for an explicit request. +- **No Claude attribution** in commit messages. diff --git a/.claude/worktrees/agent-a84e8640/CODE_OF_CONDUCT.md b/.claude/worktrees/agent-a84e8640/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..037b64c --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +gwillish55@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/.claude/worktrees/agent-a84e8640/CONTRIBUTING.md b/.claude/worktrees/agent-a84e8640/CONTRIBUTING.md new file mode 100644 index 0000000..280ddbf --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/CONTRIBUTING.md @@ -0,0 +1,121 @@ +# Contributing to DaggerheartModels + +A Swift package providing the Daggerheart model layer — the types that describe +adversaries, environments, encounters, and content packs — as well as observable +stores and CLI tools built on top of them. + +--- + +## Community attribution + +The JSON field names and schema conventions used by this package are derived from +the community ecosystem. If you are proposing changes to existing field names, +the `.dhpack` JSON Schema, or the adversary/environment JSON format, please +consider compatibility with these sources: + +| Project | Contribution | +|---|---| +| [seansbox/daggerheart-srd](https://github.com/seansbox/daggerheart-srd) | Primary source for `adversaries.json` / `environments.json` field names and SRD content in JSON | +| [ly0va/beastvault](https://github.com/ly0va/beastvault) | Community adversary YAML/JSON import schema used by Obsidian users | +| [javalent/fantasy-statblocks](https://github.com/javalent/fantasy-statblocks) | Statblock field naming conventions | +| [daggersearch/daggerheart-data](https://github.com/daggersearch/daggerheart-data) | Player-facing SRD content schema (classes, ancestries, items) | + +Maintaining compatibility with these schemas is a goal so that content created +for the broader community can be imported without transformation. + +--- + +## Scope + +### In scope + +- Model types in `DaggerheartModels`: `Adversary`, `DaggerheartEnvironment`, + `EncounterDefinition`, `DHPackContent`, and related value types +- Observable stores in `DaggerheartKit`: `Compendium`, `EncounterStore`, + `EncounterSession`, `SessionRegistry` +- The `validate-dhpack` CLI tool +- The `.dhpack` JSON Schema at `schemas/dhpack.schema.json` +- Test coverage for all of the above + +### Out of scope + +- SwiftUI views or other UI specific pieces. +- App-level lifecycle, navigation, or settings +- New content types not grounded in the Daggerheart SRD or `.dhpack` format + +--- + +## Proposing changes + +1. **Open an issue first.** Discuss before writing code. +2. **A human must be the proposer.** AI-generated feature proposals without a + human author will not be considered. +3. For behavioral changes to `DHPackContent` or the `.dhpack` JSON Schema, explain + compatibility impact — existing pack files should continue to decode correctly. + +--- + +## Code expectations + +- **Language:** Swift 6.2, `.swiftLanguageMode(.v6)` +- **`DaggerheartModels` target:** Foundation-only. No `import Observation`, + `import AppKit`, `import UIKit`, or any other Apple-platform-only framework. + Code in this target must compile and run on Linux. +- **`DaggerheartKit` target:** Apple-platform `@Observable` stores. + Uses `import Observation` and `import DaggerheartModels`. +- **`MemberImportVisibility` is active.** Every file must explicitly import the + module that defines the types it uses. `import DaggerheartKit` does not make + `Adversary` visible — add `import DaggerheartModels` too. +- **No force-unwrap** anywhere in source or test code. +- **Access control:** `public` on all exported types and their stored properties. +- **Naming:** use Daggerheart game terms as-is (`hp`, `stress`, `thresholds`). + +--- + +## Formatting + +This project uses **`swift-format`** (the built-in Swift toolchain formatter). +The `.swift-format` file at the project root is the canonical configuration. + +Run before every commit: + +```bash +./Scripts/format.sh +``` + +PRs with formatting violations will be asked to reformat before review. + +--- + +## Testing + +Tests use **Swift Testing** (`import Testing`), not XCTest. + +```bash +# Run all tests +swift test + +# Linux-safe model tests (what CI runs) +swift test --filter DaggerheartModelsTests +``` + +Guidelines: + +- Write a failing test first — red before green. +- Tests that use `@Observable` stores or Apple-only APIs belong in `DaggerheartKitTests`. +- Tests that should also run on Linux belong in `DaggerheartModelsTests`. Keep them + free of `Compendium`, `EncounterStore`, and other Apple-only types. +- Place JSON fixtures in `Tests/DaggerheartModelsTests/Fixtures/` and access them + via `Bundle.module.url(forResource:withExtension:subdirectory: "Fixtures")`. + +--- + +## Pull request process + +1. **Link to an issue.** Every PR must reference the issue it addresses. +2. **Human author required.** AI-generated code is permitted; the PR author and at + least one reviewer must be human. +3. **Tests required.** New behavior without tests will not be merged. +4. **swift-format clean.** Run `./Scripts/format.sh` before pushing. +5. **Linux CI must pass.** The `DaggerheartModelsTests` suite runs on Linux in CI — + do not introduce Linux-incompatible code into the `DaggerheartModels` target. diff --git a/.claude/worktrees/agent-a84e8640/LICENSE b/.claude/worktrees/agent-a84e8640/LICENSE new file mode 100644 index 0000000..647d969 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Fritz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.claude/worktrees/agent-a84e8640/Package.swift b/.claude/worktrees/agent-a84e8640/Package.swift new file mode 100644 index 0000000..9cf227a --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Package.swift @@ -0,0 +1,81 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let sharedSettings: [SwiftSetting] = [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("MemberImportVisibility"), +] + +var products: [Product] = [ + .library(name: "DaggerheartModels", targets: ["DaggerheartModels"]), + .executable(name: "validate-dhpack", targets: ["validate-dhpack"]), +] + +var targets: [Target] = [ + // Pure Codable value types — no Apple-only imports, compiles on Linux. + .target( + name: "DaggerheartModels", + swiftSettings: sharedSettings + ), + + // CLI tool for validating .dhpack files — depends only on DaggerheartModels. + .executableTarget( + name: "validate-dhpack", + dependencies: [ + "DaggerheartModels", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + swiftSettings: sharedSettings + ), + + // Tests for DaggerheartModels — run on Linux in CI. + .testTarget( + name: "DaggerheartModelsTests", + dependencies: ["DaggerheartModels"], + resources: [.copy("Fixtures")], + swiftSettings: sharedSettings + ), +] + +#if canImport(Darwin) + products.append(.library(name: "DaggerheartKit", targets: ["DaggerheartKit"])) + targets += [ + // Observable stores + SRD bundle resources — Apple platforms only. + .target( + name: "DaggerheartKit", + dependencies: [ + "DaggerheartModels", + .product(name: "Logging", package: "swift-log"), + ], + resources: [ + .copy("Resources/adversaries.json"), + .copy("Resources/environments.json"), + ], + swiftSettings: sharedSettings + [.defaultIsolation(MainActor.self)] + ), + + // Tests for DaggerheartKit — Apple platforms only. + .testTarget( + name: "DaggerheartKitTests", + dependencies: ["DaggerheartKit"], + swiftSettings: sharedSettings + [.defaultIsolation(MainActor.self)] + ), + ] +#endif + +let package = Package( + name: "DaggerheartModels", + platforms: [ + .iOS(.v17), + .macOS(.v14), + .tvOS(.v17), + .watchOS(.v10), + ], + products: products, + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + ], + targets: targets +) diff --git a/.claude/worktrees/agent-a84e8640/README.md b/.claude/worktrees/agent-a84e8640/README.md new file mode 100644 index 0000000..d90c311 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/README.md @@ -0,0 +1,134 @@ +# DaggerheartModels + +This is a Swift Package built to support creating tools for the TTRPG Daggerheart. +There's a great community growing that is assembling standard ways to share details +on players, adversaries, environments, and more in a JSON format. + +This package provides type-safe models for the Swift programming language, and +some validation and extensions on those models, including a JSONSchema +declaration, to hopefully promote more cross app, language, and platform tool +sharing. + +The JSON field names and schema conventions used by this package are derived from +the community ecosystem: + +| Project | Author | Contribution | +|---|---|---| +| [seansbox/daggerheart-srd](https://github.com/seansbox/daggerheart-srd) | Sean Box | SRD content in JSON/CSV/Markdown; primary source for `adversaries.json` and `environments.json` field names | +| [ly0va/beastvault](https://github.com/ly0va/beastvault) | ly0va | Obsidian plugin; defined the adversary YAML/JSON import schema used by the community | +| [javalent/fantasy-statblocks](https://github.com/javalent/fantasy-statblocks) | Jeremy Valentine | Obsidian statblock layout that established community field naming conventions | +| [daggersearch/daggerheart-data](https://github.com/daggersearch/daggerheart-data) | daggersearch | Player-facing SRD content (classes, ancestries, items) in JSON | + +Many thanks to these contributors for their work establishing the shared data +formats that make cross-tool compatibility possible. + +[Documentation for DaggerheartModels](https://swiftpackageindex.com/gwillish/DaggerheartModels/documentation/daggerheartmodels) +is hosted on the **Swift Package Index**. + +--- + +## What's inside + +### `DaggerheartModels` + +Pure value types (structs and enums) that model Daggerheart catalog and encounter +data. No UIKit, AppKit, Observation, or Apple-only frameworks — safe to use on +Linux, server-side Swift, and hopefully Wasm as well. + +| Type | Purpose | +|---|---| +| `Adversary` | Catalog entry for a Daggerheart adversary (stats, features, thresholds) | +| `AdversaryType` | Adversary role enum: Bruiser, Horde, Leader, Minion, Ranged, Skulk, Social, Solo, Standard, Support | +| `AdversaryFeature` | Named action, reaction, or passive on an adversary or environment | +| `FeatureType` | Feature category enum: action, reaction, passive | +| `AttackRange` | Attack range enum: Melee, Very Close, Close, Far, Very Far | +| `DaggerheartEnvironment` | Catalog entry for a scene environment | +| `EncounterDefinition` | Saved encounter definition (name, adversary roster, GM notes) | +| `PlayerSlot` | Player configuration within an encounter definition | +| `DHPackContent` | Top-level type for `.dhpack` content pack files | +| `ContentSource` | Remote content source (URL, display name, cache metadata) | +| `ContentFingerprint` | Snapshot hash + etag for change detection | +| `ContentStoreError` | Errors from content source management | +| `DifficultyBudget` | Difficulty assessment helpers | +| `Condition` | Status condition (name, description) | + +### `DaggerheartKit` — Apple-platform `@Observable` stores + +`@MainActor` observable classes for SwiftUI integration. Requires Apple platforms +(iOS 17+, macOS 14+, tvOS 17+, watchOS 10+). Depends on `DaggerheartModels` and +`swift-log`. + +| Type | Purpose | +|---|---| +| `Compendium` | Loads SRD adversary and environment JSON from the bundle; supports homebrew and community source packs; full-text search | +| `EncounterStore` | Persists `EncounterDefinition` files to disk; create, save, delete, duplicate | +| `EncounterSession` | Runtime mutable state for a live encounter: HP/stress tracking, adversary slots, player slots | +| `SessionRegistry` | Cache of active `EncounterSession` instances keyed by encounter ID | +| `CompendiumError` | Errors from Compendium loading | +| `EncounterStoreError` | Errors from EncounterStore persistence | + +### `validate-dhpack` — CLI tool + +Command-line tool that validates one or more `.dhpack` files against the +`DHPackContent` decoder and reports pass/fail: + +```bash +swift run validate-dhpack my-pack.dhpack +``` + +--- + +## Content packs + +Community and homebrew content is distributed as `.dhpack` files — plain JSON +containing adversaries, environments, or both. + +The JSON Schema for `.dhpack` is at [`schemas/dhpack.schema.json`](schemas/dhpack.schema.json). +Add `"$schema"` to your pack file to enable validation and autocomplete in VS Code: + +```json +{ + "$schema": "https://cdn.jsdelivr.net/gh/gwillish/DaggerheartModels@0.1.1/schemas/dhpack.schema.json", + "adversaries": [ ... ] +} +``` + +Use `validate-dhpack` to check a pack file against the decoder: + +```bash +swift run validate-dhpack my-pack.dhpack +``` + +A complete field reference is in [`docs/dhpack-format.md`](docs/dhpack-format.md). + +--- + +## Adding to your project + +```swift +// Package.swift +.package(url: "https://github.com/gwillish/DaggerheartModels.git", from: "0.1.0"), + +// Apple-platform app target +.product(name: "DaggerheartKit", package: "DaggerheartModels"), + +// Linux / server target (models only) +.product(name: "DaggerheartModels", package: "DaggerheartModels"), +``` + +--- + +## Building and testing + +```bash +# Build both library targets +swift build + +# Run all tests +swift test + +# Linux-safe model tests only +swift test --filter DaggerheartModelsTests +``` + +See `CLAUDE.md` for the full development guide. diff --git a/.claude/worktrees/agent-a84e8640/Scripts/format.sh b/.claude/worktrees/agent-a84e8640/Scripts/format.sh new file mode 100755 index 0000000..8d0f3ab --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Scripts/format.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Move to the project root +cd "$(dirname "$0")" || exit +cd .. +echo "Formatting Swift sources in $(pwd)" + +# Run the format / lint commands +git ls-files -z '*.swift' | xargs -0 swift format format --parallel --in-place +git ls-files -z '*.swift' | xargs -0 swift format lint --strict --parallel diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/Compendium.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/Compendium.swift new file mode 100644 index 0000000..8a1c668 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/Compendium.swift @@ -0,0 +1,354 @@ +// +// Compendium.swift +// Encounter +// +// Observable data store that loads Daggerheart catalog JSON from the +// app bundle and provides lookup APIs for adversaries and environments. +// +// Data sources (bundled JSON files in Encounter/Resources/): +// adversaries.json — from seansbox/daggerheart-srd .build/json/ +// environments.json — from seansbox/daggerheart-srd .build/json/ +// +// Both files contain a top-level JSON array of objects. +// See docs/data-schema.md for the complete field reference. +// + +import DaggerheartModels +import Foundation +import Logging +import Observation + +// MARK: - CompendiumError + +/// Errors that can occur while loading compendium data. +nonisolated public enum CompendiumError: Error, LocalizedError { + case fileNotFound(resourceName: String) + case decodingFailed(resourceName: String, underlying: Error) + + public var errorDescription: String? { + switch self { + case .fileNotFound(let resourceName): + return "Compendium resource '\(resourceName)' not found in app bundle." + case .decodingFailed(let resourceName, let underlying): + return "Failed to decode '\(resourceName)': \(underlying.localizedDescription)" + } + } +} + +// MARK: - Compendium + +/// The central catalog of Daggerheart adversaries and environments. +/// +/// `Compendium` is an `@Observable` class intended to be injected into +/// the SwiftUI environment once at app launch and shared across all views. +/// +/// ```swift +/// // In EncounterApp.swift: +/// @State private var compendium = Compendium() +/// +/// var body: some Scene { +/// WindowGroup { +/// ContentView() +/// .environment(compendium) +/// .task { try? await compendium.load() } +/// } +/// } +/// ``` +/// +/// ## Loading +/// Call ``load()`` once during app startup. It decodes both JSON files +/// from the bundle on a background task and publishes the results. +/// +/// ## Homebrew +/// Call ``addAdversary(_:)`` / ``addEnvironment(_:)`` to merge homebrew +/// entries at runtime. Homebrew entries with the same `id` as an SRD entry +/// replace the SRD version. +@MainActor +@Observable +public final class Compendium { + + private let logger = Logger(label: "Compendium") + + // MARK: Published State + + /// SRD adversaries loaded from the bundle, keyed by slug. + private var srdAdversariesByID: [String: Adversary] = [:] + + /// Community source packs, keyed by source ID then by adversary slug. + /// Allows packs to be added and removed independently without a full rebuild. + private var sourcesAdversariesByID: [String: [String: Adversary]] = [:] + + /// Homebrew adversaries added at runtime, keyed by slug. + /// Homebrew entries with the same `id` as any source or SRD entry take priority. + private var homebrewAdversariesByID: [String: Adversary] = [:] + + /// SRD environments loaded from the bundle, keyed by slug. + private var srdEnvironmentsByID: [String: DaggerheartEnvironment] = [:] + + /// Community source pack environments, keyed by source ID then by environment slug. + private var sourcesEnvironmentsByID: [String: [String: DaggerheartEnvironment]] = [:] + + /// Homebrew environments added at runtime, keyed by slug. + private var homebrewEnvironmentsByID: [String: DaggerheartEnvironment] = [:] + + /// Cached result of the last adversary merge. `nil` when the cache is dirty. + private var _cachedAdversariesByID: [String: Adversary]? + + /// Cached result of the last environment merge. `nil` when the cache is dirty. + private var _cachedEnvironmentsByID: [String: DaggerheartEnvironment]? + + /// All adversaries merged in priority order: homebrew → sources → srd. + /// Within sources, higher-priority packs should be inserted last to win conflicts. + /// Result is cached; invalidated whenever any source bucket changes. + /// + /// - Complexity: O(*n*) on cache miss, where *n* is the total adversary count across all sources. + public var adversariesByID: [String: Adversary] { + if let cached = _cachedAdversariesByID { return cached } + var merged = srdAdversariesByID + for packAdversaries in sourcesAdversariesByID.values { + merged.merge(packAdversaries) { _, source in source } + } + merged.merge(homebrewAdversariesByID) { _, homebrew in homebrew } + _cachedAdversariesByID = merged + return merged + } + + /// All environments merged in priority order: homebrew → sources → srd. + /// Result is cached; invalidated whenever any source bucket changes. + /// + /// - Complexity: O(*n*) on cache miss, where *n* is the total environment count across all sources. + public var environmentsByID: [String: DaggerheartEnvironment] { + if let cached = _cachedEnvironmentsByID { return cached } + var merged = srdEnvironmentsByID + for packEnvironments in sourcesEnvironmentsByID.values { + merged.merge(packEnvironments) { _, source in source } + } + merged.merge(homebrewEnvironmentsByID) { _, homebrew in homebrew } + _cachedEnvironmentsByID = merged + return merged + } + + /// Sorted array of all adversaries (for list views). + /// + /// - Complexity: O(*n* log *n*) where *n* is the total adversary count. + public var adversaries: [Adversary] { + adversariesByID.values.sorted { $0.name < $1.name } + } + + /// Sorted array of all environments. + /// + /// - Complexity: O(*n* log *n*) where *n* is the total environment count. + public var environments: [DaggerheartEnvironment] { + environmentsByID.values.sorted { $0.name < $1.name } + } + + /// Sorted array of homebrew-only adversaries. + /// + /// - Complexity: O(*k* log *k*) where *k* is the homebrew adversary count. + public var homebrewAdversaries: [Adversary] { + homebrewAdversariesByID.values.sorted { $0.name < $1.name } + } + + /// Sorted array of homebrew-only environments. + /// + /// - Complexity: O(*k* log *k*) where *k* is the homebrew environment count. + public var homebrewEnvironments: [DaggerheartEnvironment] { + homebrewEnvironmentsByID.values.sorted { $0.name < $1.name } + } + + /// `true` while JSON loading is in progress. + public private(set) var isLoading: Bool = false + + /// Non-nil if the last load attempt failed. + public private(set) var loadError: CompendiumError? + + // MARK: - Init + + /// The bundle used to locate SRD JSON resources. + private let bundle: Bundle + + /// Creates a compendium. + /// + /// - Parameter bundle: The bundle containing `adversaries.json` and + /// `environments.json`. Pass `nil` (the default) to use the `DaggerheartKit` + /// module bundle, which ships the full SRD data. Pass an explicit bundle in + /// tests or to exercise the error path when resources are absent. + public init(bundle: Bundle? = nil) { + // Bundle.module is internal and cannot appear in a default argument value, + // so we resolve it here inside the module body instead. + self.bundle = bundle ?? .module + } + + // MARK: - Loading + + /// Load the SRD data from bundle resources. + /// + /// JSON decoding is performed on a background task; results are published + /// back on the main actor. Safe to call multiple times — concurrent calls + /// while a load is already in progress are ignored. + /// + /// Throws a ``CompendiumError`` if a resource is missing or malformed. + /// The error is also stored in ``loadError`` for SwiftUI observation. + public func load() async throws { + guard !isLoading else { + logger.debug("load() called while already loading — skipped") + return + } + isLoading = true + loadError = nil + logger.info("Compendium load started") + + defer { isLoading = false } + + do { + async let adversaries = Self.decodeArray( + Adversary.self, fromResource: "adversaries", bundle: bundle) + async let environments = Self.decodeArray( + DaggerheartEnvironment.self, fromResource: "environments", bundle: bundle) + let (loadedAdversaries, loadedEnvironments) = try await (adversaries, environments) + + srdAdversariesByID = Dictionary(uniqueKeysWithValues: loadedAdversaries.map { ($0.id, $0) }) + srdEnvironmentsByID = Dictionary(uniqueKeysWithValues: loadedEnvironments.map { ($0.id, $0) }) + _cachedAdversariesByID = nil + _cachedEnvironmentsByID = nil + logger.info( + "Compendium loaded \(loadedAdversaries.count) adversaries, \(loadedEnvironments.count) environments" + ) + } catch let error as CompendiumError { + loadError = error + logger.error("Compendium load failed: \(error.localizedDescription)") + throw error + } catch { + let wrapped = CompendiumError.decodingFailed(resourceName: "unknown", underlying: error) + loadError = wrapped + logger.error("Compendium load failed (unexpected): \(error)") + throw wrapped + } + } + + // MARK: - Lookup + + /// Look up an adversary by slug, respecting the full priority order: + /// homebrew → sources → srd. + public func adversary(id: String) -> Adversary? { + adversariesByID[id] + } + + /// Look up an environment by slug, respecting the full priority order: + /// homebrew → sources → srd. + public func environment(id: String) -> DaggerheartEnvironment? { + environmentsByID[id] + } + + /// Return all adversaries for a given tier. + public func adversaries(tier: Int) -> [Adversary] { + adversaries.filter { $0.tier == tier } + } + + /// Return all adversaries of a given type. + public func adversaries(type: AdversaryType) -> [Adversary] { + adversaries.filter { $0.type == type } + } + + /// Full-text search across adversary names and descriptions. + /// Uses `localizedStandardContains` for diacritic- and case-insensitive matching. + public func searchAdversaries(query: String) -> [Adversary] { + guard !query.isEmpty else { return adversaries } + return adversaries.filter { + $0.name.localizedStandardContains(query) || $0.flavorText.localizedStandardContains(query) + } + } + + // MARK: - SRD Reload + + /// Replace the SRD adversary and environment dictionaries. + /// + /// Called by `ContentStore` after downloading a new SRD content pack. + /// The swap is atomic from the observation system's perspective. + public func replaceSRDContent(adversaries: [Adversary], environments: [DaggerheartEnvironment]) { + srdAdversariesByID = Dictionary(uniqueKeysWithValues: adversaries.map { ($0.id, $0) }) + srdEnvironmentsByID = Dictionary(uniqueKeysWithValues: environments.map { ($0.id, $0) }) + _cachedAdversariesByID = nil + _cachedEnvironmentsByID = nil + logger.info( + "Compendium SRD content replaced: \(adversaries.count) adversaries, \(environments.count) environments" + ) + } + + // MARK: - Source Pack Management + + /// Install or replace a community source pack. + /// + /// The `sourceID` is the stable identifier for the pack (e.g. `"expanded-adversary-compendium"`). + /// Calling this again with the same `sourceID` replaces the previous pack entirely. + public func replaceSourceContent( + sourceID: String, + adversaries: [Adversary], + environments: [DaggerheartEnvironment] + ) { + sourcesAdversariesByID[sourceID] = Dictionary( + uniqueKeysWithValues: adversaries.map { ($0.id, $0) }) + sourcesEnvironmentsByID[sourceID] = Dictionary( + uniqueKeysWithValues: environments.map { ($0.id, $0) }) + _cachedAdversariesByID = nil + _cachedEnvironmentsByID = nil + logger.info( + "Compendium source '\(sourceID)' replaced: \(adversaries.count) adversaries, \(environments.count) environments" + ) + } + + /// Remove a community source pack entirely. + /// No-op if the `sourceID` is not present. + public func removeSourceContent(sourceID: String) { + sourcesAdversariesByID.removeValue(forKey: sourceID) + sourcesEnvironmentsByID.removeValue(forKey: sourceID) + _cachedAdversariesByID = nil + _cachedEnvironmentsByID = nil + logger.info("Compendium source '\(sourceID)' removed") + } + + // MARK: - Homebrew + + /// Add or replace a homebrew adversary. + /// Homebrew entries shadow SRD and source pack entries with the same `id`. + public func addAdversary(_ adversary: Adversary) { + homebrewAdversariesByID[adversary.id] = adversary + _cachedAdversariesByID = nil + } + + /// Remove a homebrew adversary by slug. No-op if not present. + public func removeHomebrewAdversary(id: String) { + homebrewAdversariesByID.removeValue(forKey: id) + _cachedAdversariesByID = nil + } + + /// Add or replace a homebrew environment. + public func addEnvironment(_ environment: DaggerheartEnvironment) { + homebrewEnvironmentsByID[environment.id] = environment + _cachedEnvironmentsByID = nil + } + + /// Remove a homebrew environment by slug. No-op if not present. + public func removeHomebrewEnvironment(id: String) { + homebrewEnvironmentsByID.removeValue(forKey: id) + _cachedEnvironmentsByID = nil + } + + // MARK: - Private Helpers + + nonisolated private static func decodeArray( + _ type: T.Type, fromResource name: String, bundle: Bundle + ) async throws -> [T] { + guard let url = bundle.url(forResource: name, withExtension: "json") else { + throw CompendiumError.fileNotFound(resourceName: "\(name).json") + } + do { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode([T].self, from: data) + } catch let error as CompendiumError { + throw error + } catch { + throw CompendiumError.decodingFailed(resourceName: "\(name).json", underlying: error) + } + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/DaggerheartKit.docc/DaggerheartKit.md b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/DaggerheartKit.docc/DaggerheartKit.md new file mode 100644 index 0000000..f37a104 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/DaggerheartKit.docc/DaggerheartKit.md @@ -0,0 +1,29 @@ +# ``DaggerheartKit`` + +Observable SwiftUI stores for running Daggerheart encounters on Apple platforms. + +## Overview + +`DaggerheartKit` provides `@Observable` classes that drive SwiftUI views in the +Encounter app. It depends on ``DaggerheartModels`` for the underlying value types. + +All types require Apple platforms (iOS 17+, macOS 14+) and are isolated to +`@MainActor` by default. + +## Topics + +### Compendium + +- ``Compendium`` + +### Encounter Persistence + +- ``EncounterStore`` +- ``EncounterStoreError`` + +### Live Session + +- ``EncounterSession`` +- ``AdversarySlot`` +- ``EnvironmentSlot`` +- ``SessionRegistry`` diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/EncounterSession.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/EncounterSession.swift new file mode 100644 index 0000000..cc8cdb6 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/EncounterSession.swift @@ -0,0 +1,440 @@ +// +// EncounterSession.swift +// Encounter +// +// Runtime models for a live Daggerheart encounter. +// These are the mutable, in-play tracking types — separate from the +// static catalog definitions in Adversary.swift. +// +// Design notes: +// - EncounterSession is @Observable so SwiftUI views bind to it directly. +// - AdversarySlot and EnvironmentSlot are structs stored in the session's +// arrays; mutations flow through the session class. +// - Fear and Hope are tracked on the session; individual adversary stress +// contributes to Fear when thresholds are crossed (GM's discretion). +// - The `spotlightedSlotID` drives spotlight management in the UI. +// + +import DaggerheartModels +import Foundation +import Logging +import Observation + +// MARK: - AdversarySlot + +/// A single adversary participant in a live encounter. +/// +/// Wraps a reference to a catalog ``Adversary`` with runtime mutable state: +/// current HP, current Stress, defeat status, and an optional individual name +/// (useful when running multiple copies of the same adversary). +/// +/// `maxHP` and `maxStress` are snapshotted from the catalog at slot-creation +/// time so that HP/stress clamping works correctly even if the source adversary +/// is later edited or removed from the ``Compendium`` (homebrew orphan safety). +nonisolated public struct AdversarySlot: CombatParticipant, Sendable, Equatable, Hashable { + public let id: UUID + /// The slug that identifies this adversary in the ``Compendium``. + public let adversaryID: String + /// Display name override (e.g. "Grimfang" for a named bandit leader). + /// Falls back to the catalog name when `nil`. + public var customName: String? + + // MARK: Stat Snapshot (from catalog at creation time) + public let maxHP: Int + public let maxStress: Int + + // MARK: Tracked Stats + public var currentHP: Int + public var currentStress: Int + public var isDefeated: Bool + public var conditions: Set + + // MARK: - Init + + public init( + id: UUID = UUID(), + adversaryID: String, + customName: String? = nil, + maxHP: Int, + maxStress: Int, + currentHP: Int? = nil, + currentStress: Int = 0, + isDefeated: Bool = false, + conditions: Set = [] + ) { + self.id = id + self.adversaryID = adversaryID + self.customName = customName + self.maxHP = maxHP + self.maxStress = maxStress + self.currentHP = currentHP ?? maxHP + self.currentStress = currentStress + self.isDefeated = isDefeated + self.conditions = conditions + } + + /// Convenience factory: create a slot pre-populated from a catalog entry. + public static func from(_ adversary: Adversary, customName: String? = nil) -> AdversarySlot { + AdversarySlot( + adversaryID: adversary.id, + customName: customName, + maxHP: adversary.hp, + maxStress: adversary.stress + ) + } +} + +// MARK: - EnvironmentSlot + +/// An environment element active in the current encounter scene. +/// +/// Environments have no HP or Stress — they are tracked only for +/// their features and activation state. +nonisolated public struct EnvironmentSlot: EncounterParticipant, Sendable, Equatable, Hashable { + public let id: UUID + /// The slug identifying this environment in the ``Compendium``. + public let environmentID: String + /// Whether this environment element is currently active/visible to players. + public var isActive: Bool + + public init( + id: UUID = UUID(), + environmentID: String, + isActive: Bool = true + ) { + self.id = id + self.environmentID = environmentID + self.isActive = isActive + } +} + +// MARK: - EncounterSession + +/// The live state of a Daggerheart encounter being run at the table. +/// +/// `EncounterSession` is the central observable object for encounter views. +/// It holds: +/// - The roster of active adversary and environment slots. +/// - The GM's Fear pool and the party's Hope pool. +/// - Spotlight management (which adversary/environment is currently active). +/// - A freeform GM notes field. +/// +/// ## Usage +/// Create a session by adding slots from the ``Compendium``, then pass it +/// through the environment to encounter views. +/// +/// ```swift +/// let session = EncounterSession(name: "Bandit Ambush") +/// session.add(adversary: bandits.ironguard) +/// session.add(adversary: bandits.ironguard) // second copy +/// session.add(environment: terrain.forestEdge) +/// ``` +@MainActor +@Observable +public final class EncounterSession: Identifiable, Hashable { + public nonisolated static func == (lhs: EncounterSession, rhs: EncounterSession) -> Bool { + lhs.id == rhs.id + } + public nonisolated func hash(into hasher: inout Hasher) { hasher.combine(id) } + + private let logger = Logger(label: "EncounterSession") + + // MARK: Identity + public let id: UUID + public var name: String + + // MARK: Participants + public var adversarySlots: [AdversarySlot] + public var playerSlots: [PlayerSlot] + public var environmentSlots: [EnvironmentSlot] + + // MARK: Fear & Hope + /// The GM's Fear pool. Increases when players roll with Fear, + /// decreases when the GM spends Fear on adversary actions. + public var fearPool: Int + + /// The party's Hope pool (total across all PCs). Tracked here for + /// quick reference; primary source of truth is player character sheets. + public var hopePool: Int + + // MARK: Spotlight + /// The ID of the adversary, environment, or player slot currently in the spotlight. + /// `nil` when it is the players' action phase. + public var spotlightedSlotID: UUID? + + /// Running total of spotlight grants in this encounter. + public var spotlightCount: Int + + // MARK: Notes + public var gmNotes: String + + // MARK: - Init + + public init( + id: UUID = UUID(), + name: String, + adversarySlots: [AdversarySlot] = [], + playerSlots: [PlayerSlot] = [], + environmentSlots: [EnvironmentSlot] = [], + fearPool: Int = 0, + hopePool: Int = 0, + spotlightCount: Int = 0, + gmNotes: String = "" + ) { + self.id = id + self.name = name + self.adversarySlots = adversarySlots + self.playerSlots = playerSlots + self.environmentSlots = environmentSlots + self.fearPool = fearPool + self.hopePool = hopePool + self.spotlightedSlotID = nil + self.spotlightCount = spotlightCount + self.gmNotes = gmNotes + } + + // MARK: - Roster Management + + /// Add a new adversary slot populated from a catalog entry. + public func add(adversary: Adversary, customName: String? = nil) { + let slot = AdversarySlot.from(adversary, customName: customName) + adversarySlots.append(slot) + } + + /// Add an environment slot. + public func add(environment: DaggerheartEnvironment) { + environmentSlots.append(EnvironmentSlot(environmentID: environment.id)) + } + + /// Remove an adversary slot by ID. + public func removeAdversary(id: UUID) { + adversarySlots.removeAll { $0.id == id } + if spotlightedSlotID == id { spotlightedSlotID = nil } + } + + // MARK: - Player Management + + /// Add a player slot to the encounter. + public func addPlayer(_ player: PlayerSlot) { + playerSlots.append(player) + } + + /// Remove a player slot by ID. + public func removePlayer(id: UUID) { + playerSlots.removeAll { $0.id == id } + if spotlightedSlotID == id { spotlightedSlotID = nil } + } + + // MARK: - Spotlight + + /// Grant the spotlight to an adversary, environment, or player slot. + /// + /// Increments ``spotlightCount`` on every call. The GM typically + /// spends 1 Fear (tracked separately on ``fearPool``) when seizing + /// the spotlight to act. + public func spotlight(_ participant: some EncounterParticipant) { + spotlightedSlotID = participant.id + spotlightCount += 1 + } + + /// Yield the spotlight back to the players, ending the GM's turn. + /// + /// Clears ``spotlightedSlotID``. The spotlight returning to the players + /// is the natural end of a GM turn in Daggerheart. + public func yieldSpotlight() { + spotlightedSlotID = nil + } + + // MARK: - HP & Stress Mutations + + /// Apply damage to any combat participant, clamping HP to 0. + /// Adversary slots are marked ``AdversarySlot/isDefeated`` when HP reaches 0. + public func applyDamage(_ amount: Int, to participant: some CombatParticipant) { + let id = participant.id + if let i = adversarySlots.firstIndex(where: { $0.id == id }) { + adversarySlots[i].currentHP = max(0, adversarySlots[i].currentHP - amount) + if adversarySlots[i].currentHP == 0 { + adversarySlots[i].isDefeated = true + logger.info("Slot \(id) defeated") + } else { + logger.debug("Slot \(id) took \(amount) damage, HP now \(self.adversarySlots[i].currentHP)") + } + return + } + if let i = playerSlots.firstIndex(where: { $0.id == id }) { + playerSlots[i].currentHP = max(0, playerSlots[i].currentHP - amount) + } + } + + /// Heal any combat participant, clamping HP to the slot's maximum. + /// Clears ``AdversarySlot/isDefeated`` if the adversary's HP rises above 0. + public func heal(_ amount: Int, to participant: some CombatParticipant) { + let id = participant.id + if let i = adversarySlots.firstIndex(where: { $0.id == id }) { + adversarySlots[i].currentHP = min( + adversarySlots[i].maxHP, adversarySlots[i].currentHP + amount) + if adversarySlots[i].currentHP > 0 { adversarySlots[i].isDefeated = false } + logger.debug( + "Slot \(id) healed \(amount), HP now \(self.adversarySlots[i].currentHP)/\(self.adversarySlots[i].maxHP)" + ) + return + } + if let i = playerSlots.firstIndex(where: { $0.id == id }) { + playerSlots[i].currentHP = min(playerSlots[i].maxHP, playerSlots[i].currentHP + amount) + } + } + + /// Apply stress to any combat participant, clamping to the slot's maximum. + public func applyStress(_ amount: Int, to participant: some CombatParticipant) { + let id = participant.id + if modifying( + in: &adversarySlots, id: id, + { $0.currentStress = min($0.maxStress, $0.currentStress + amount) }) + { + return + } + modifying(in: &playerSlots, id: id) { + $0.currentStress = min($0.maxStress, $0.currentStress + amount) + } + } + + /// Reduce stress on any combat participant, clamping to 0. + public func reduceStress(_ amount: Int, to participant: some CombatParticipant) { + let id = participant.id + if modifying( + in: &adversarySlots, id: id, { $0.currentStress = max(0, $0.currentStress - amount) }) + { + return + } + modifying(in: &playerSlots, id: id) { $0.currentStress = max(0, $0.currentStress - amount) } + } + + // MARK: - Condition Management + + /// Apply a condition to any combat participant. + /// Per the SRD, the same condition cannot stack — ``Set`` enforces this. + /// `.custom` conditions with an empty or whitespace-only name are silently ignored. + public func applyCondition(_ condition: Condition, to participant: some CombatParticipant) { + if case .custom(let name) = condition, + name.trimmingCharacters(in: .whitespaces).isEmpty + { + return + } + let id = participant.id + if modifying(in: &adversarySlots, id: id, { $0.conditions.insert(condition) }) { return } + modifying(in: &playerSlots, id: id) { $0.conditions.insert(condition) } + } + + /// Remove a condition from any combat participant. + public func removeCondition(_ condition: Condition, from participant: some CombatParticipant) { + let id = participant.id + if modifying(in: &adversarySlots, id: id, { $0.conditions.remove(condition) }) { return } + modifying(in: &playerSlots, id: id) { $0.conditions.remove(condition) } + } + + // MARK: - Armor Slot Management + + /// Mark one Armor Slot on a player (used to reduce damage severity). + public func markArmorSlot(_ slotID: UUID) { + guard let index = playerSlots.firstIndex(where: { $0.id == slotID }) else { return } + guard playerSlots[index].currentArmorSlots > 0 else { return } + playerSlots[index].currentArmorSlots -= 1 + } + + /// Restore one Armor Slot on a player (undo a mark, or recover via a rest ability). + public func restoreArmorSlot(_ slotID: UUID) { + guard let index = playerSlots.firstIndex(where: { $0.id == slotID }) else { return } + playerSlots[index].currentArmorSlots = min( + playerSlots[index].armorSlots, + playerSlots[index].currentArmorSlots + 1 + ) + } + + // MARK: - Fear & Hope + + public func incrementFear(by amount: Int = 1) { + fearPool += amount + } + + public func spendFear(_ amount: Int = 1) { + fearPool = max(0, fearPool - amount) + } + + public func incrementHope(by amount: Int = 1) { + hopePool += amount + } + + public func spendHope(_ amount: Int = 1) { + hopePool = max(0, hopePool - amount) + } + + // MARK: - Computed Helpers + + /// All adversary slots still in the fight. + public var activeAdversaries: [AdversarySlot] { + adversarySlots.filter { !$0.isDefeated } + } + + /// `true` when all adversary slots are defeated. + public var isOver: Bool { + !adversarySlots.isEmpty && adversarySlots.allSatisfy(\.isDefeated) + } + + // MARK: - Private Helpers + + @discardableResult + private func modifying( + in slots: inout [S], id: UUID, _ body: (inout S) -> Void + ) -> Bool { + guard let i = slots.firstIndex(where: { $0.id == id }) else { return false } + body(&slots[i]) + return true + } + + // MARK: - Factory + + /// Create a live encounter session from a saved definition. + /// + /// Resolves adversary and environment IDs through the compendium. + /// IDs that do not resolve to a catalog entry are silently skipped + /// (this handles orphaned homebrew references gracefully). + /// + /// - Parameters: + /// - definition: The encounter template to instantiate. + /// - compendium: The catalog used to resolve adversary/environment IDs. + /// - Returns: A fresh `EncounterSession` ready for play. + public static func start( + from definition: EncounterDefinition, + using compendium: Compendium + ) -> EncounterSession { + let adversarySlots: [AdversarySlot] = definition.adversaryIDs.compactMap { id in + guard let adversary = compendium.adversary(id: id) else { return nil } + return AdversarySlot.from(adversary) + } + + let environmentSlots: [EnvironmentSlot] = definition.environmentIDs.compactMap { id in + guard compendium.environment(id: id) != nil else { return nil } + return EnvironmentSlot(environmentID: id) + } + + let playerSlots: [PlayerSlot] = definition.playerConfigs.map { config in + PlayerSlot( + name: config.name, + maxHP: config.maxHP, + maxStress: config.maxStress, + evasion: config.evasion, + thresholdMajor: config.thresholdMajor, + thresholdSevere: config.thresholdSevere, + armorSlots: config.armorSlots + ) + } + + return EncounterSession( + name: definition.name, + adversarySlots: adversarySlots, + playerSlots: playerSlots, + environmentSlots: environmentSlots, + gmNotes: definition.gmNotes + ) + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/EncounterStore.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/EncounterStore.swift new file mode 100644 index 0000000..bd59649 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/EncounterStore.swift @@ -0,0 +1,301 @@ +// +// EncounterStore.swift +// Encounter +// +// Persistence layer for EncounterDefinition documents. +// Each definition is stored as a JSON file named .encounter.json +// in a single flat directory. +// +// Storage location (resolved once at app launch via defaultDirectory()): +// iCloud available: /Documents/Encounters/ +// iCloud unavailable: /Encounters/ +// +// iCloud Drive syncs the ubiquity container automatically with no +// CloudKit record API required. Requires the iCloud Documents capability +// and an NSUbiquitousContainers entry in Info.plist. +// + +import DaggerheartModels +import Foundation +import Observation + +// MARK: - EncounterStoreError + +/// Errors thrown by ``EncounterStore`` operations. +public enum EncounterStoreError: Error, LocalizedError { + case notFound(UUID) + case saveFailed(UUID, Error) + case deleteFailed(UUID, Error) + + public var errorDescription: String? { + switch self { + case .notFound(let id): + return "No encounter definition found with ID \(id)." + case .saveFailed(let id, let underlying): + return "Failed to save encounter \(id): \(underlying.localizedDescription)" + case .deleteFailed(let id, let underlying): + return "Failed to delete encounter \(id): \(underlying.localizedDescription)" + } + } +} + +// MARK: - EncounterStore + +/// Persistence layer for ``EncounterDefinition`` documents. +/// +/// Each definition is stored as a single JSON file (`.encounter.json`) +/// in a flat directory. iCloud Drive syncs the directory automatically when +/// the app is configured with the iCloud Documents capability. +/// +/// Inject into the SwiftUI environment at app launch: +/// +/// ```swift +/// @State private var store = EncounterStore(directory: EncounterStore.localDirectory) +/// +/// var body: some Scene { +/// WindowGroup { +/// ContentView() +/// .environment(store) +/// .task { +/// let dir = await EncounterStore.defaultDirectory() +/// store.relocate(to: dir) +/// await store.load() +/// } +/// } +/// } +/// ``` +@Observable @MainActor +public final class EncounterStore { + + // MARK: Public State + + /// All loaded encounter definitions, sorted by `modifiedAt` descending. + public private(set) var definitions: [EncounterDefinition] = [] + + /// The directory where `.encounter.json` files are stored. + public private(set) var directory: URL + + /// `true` while a `load()` is in progress. + public private(set) var isLoading = false + + /// Non-nil if the last `load()` failed at the directory level. + public private(set) var loadError: (any Error)? + + // MARK: - Init + + public init(directory: URL) { + self.directory = directory + } + + // MARK: - Directory Resolution + + /// Returns the preferred storage directory, using iCloud when available. + /// + /// `url(forUbiquityContainerIdentifier:)` may perform file-system operations. + /// Marked `@concurrent` so the body runs on the cooperative thread pool even + /// when called from a `@MainActor` context (required in Swift 6.2+). + @concurrent + nonisolated public static func defaultDirectory() async -> URL { + await resolveDefaultDirectory() + } + + @concurrent + nonisolated private static func resolveDefaultDirectory() async -> URL { + let fm = FileManager.default + if let ubiquity = fm.url(forUbiquityContainerIdentifier: nil) { + let dir = + ubiquity + .appending(path: "Documents") + .appending(path: "Encounters") + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + return Self.localDirectory + } + + @concurrent + nonisolated private static func readAllEncounters(from dir: URL) async throws + -> [EncounterDefinition] + { + let fm = FileManager.default + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + let contents = try fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: []) + let decoder = JSONDecoder() + return + contents + .filter { $0.lastPathComponent.hasSuffix(".encounter.json") } + .compactMap { url -> EncounterDefinition? in + guard let data = try? Data(contentsOf: url), + let def = try? decoder.decode(EncounterDefinition.self, from: data) + else { return nil } + return def + } + } + + @concurrent + nonisolated private static func writeEncounter(_ definition: EncounterDefinition, to url: URL) + async throws + { + // Create directory defensively so persist() works even if + // called before load() has had a chance to create it. + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + // JSONEncoder is allocated per-call because JSONEncoder is not + // Sendable in Swift 6 and cannot be safely shared across tasks. + let data = try JSONEncoder().encode(definition) + try data.write(to: url, options: .atomic) + } + + @concurrent + nonisolated private static func deleteEncounter(at url: URL) async throws { + try FileManager.default.removeItem(at: url) + } + + /// Local Application Support directory. A pure URL — no file I/O performed. + nonisolated public static var localDirectory: URL { + URL.applicationSupportDirectory.appending(path: "Encounters") + } + + /// Switches the storage directory and clears `definitions`. + /// Call `load()` afterwards to populate from the new location. + public func relocate(to newDirectory: URL) { + directory = newDirectory + definitions = [] + } + + // MARK: - Load + + /// Reads all `.encounter.json` files from `directory`. + /// + /// If a load is already in progress, this call returns immediately without + /// waiting for the existing load to complete and without triggering a second + /// load. Callers that need fresh data should await the first load before calling again. + /// + /// Corrupt or unreadable individual files are skipped silently. + /// Valid definitions are published via ``definitions``, sorted by + /// `modifiedAt` descending. Directory-level errors are stored in ``loadError``. + public func load() async { + guard !isLoading else { return } + isLoading = true + loadError = nil + defer { isLoading = false } + + let dir = directory + do { + let loaded = try await Self.readAllEncounters(from: dir) + definitions = loaded.sorted { $0.modifiedAt > $1.modifiedAt } + } catch { + loadError = error + } + } + + // MARK: - Create + + /// Creates a new ``EncounterDefinition``, persists it, and inserts it + /// into ``definitions``. + public func create(name: String) async throws { + let def = EncounterDefinition(name: name) + try await persist(def) + insertSorted(def) + } + + // MARK: - Save + + /// Persists an updated definition to disk and refreshes ``definitions``. + /// + /// The store stamps `modifiedAt = .now` before writing, so the sort order + /// invariant is maintained regardless of whether the caller has updated + /// individual properties through their `didSet` observers. + /// + /// - Throws: ``EncounterStoreError/notFound(_:)`` if the ID is not in + /// the current ``definitions``. + public func save(_ definition: EncounterDefinition) async throws { + guard definitions.contains(where: { $0.id == definition.id }) else { + throw EncounterStoreError.notFound(definition.id) + } + var stamped = definition + stamped.modifiedAt = .now + try await persist(stamped) + updateInPlace(stamped) + } + + // MARK: - Delete + + /// Removes a definition from memory and deletes its backing file. + /// + /// - Throws: ``EncounterStoreError/notFound(_:)`` if the ID is unknown. + public func delete(id: UUID) async throws { + guard definitions.contains(where: { $0.id == id }) else { + throw EncounterStoreError.notFound(id) + } + let url = fileURL(for: id) + do { + try await Self.deleteEncounter(at: url) + } catch { + throw EncounterStoreError.deleteFailed(id, error) + } + definitions.removeAll { $0.id == id } + } + + // MARK: - Duplicate + + /// Creates an independent copy of an existing definition with a new UUID, + /// `createdAt`, and a `" (Copy)"` suffix on the name. Persists it and + /// adds it to ``definitions``. + /// + /// The copy is inserted with `createdAt = modifiedAt = .now`, so it sorts + /// to the top of ``definitions``. + /// + /// - Note: All content fields of ``EncounterDefinition`` must be listed + /// explicitly here. When adding new fields to `EncounterDefinition`, + /// update this method to include them. + /// + /// - Throws: ``EncounterStoreError/notFound(_:)`` if the source ID is unknown. + public func duplicate(id: UUID) async throws { + guard let original = definitions.first(where: { $0.id == id }) else { + throw EncounterStoreError.notFound(id) + } + let copy = EncounterDefinition( + name: "\(original.name) (Copy)", + adversaryIDs: original.adversaryIDs, + environmentIDs: original.environmentIDs, + playerConfigs: original.playerConfigs, + gmNotes: original.gmNotes + ) + try await persist(copy) + insertSorted(copy) + } + + // MARK: - Private Helpers + + private func fileURL(for id: UUID) -> URL { + directory.appending(path: "\(id.uuidString).encounter.json") + } + + private func persist(_ definition: EncounterDefinition) async throws { + let url = fileURL(for: definition.id) + do { + try await Self.writeEncounter(definition, to: url) + } catch { + throw EncounterStoreError.saveFailed(definition.id, error) + } + } + + // Appends then re-sorts the full array. O(n log n), appropriate for + // GM-scale encounter lists (tens to low hundreds of items). + private func insertSorted(_ definition: EncounterDefinition) { + definitions.append(definition) + definitions.sort { $0.modifiedAt > $1.modifiedAt } + } + + private func updateInPlace(_ definition: EncounterDefinition) { + guard let idx = definitions.firstIndex(where: { $0.id == definition.id }) else { + assertionFailure("updateInPlace called for unknown id \(definition.id)") + return + } + definitions[idx] = definition + definitions.sort { $0.modifiedAt > $1.modifiedAt } + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/Resources/adversaries.json b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/Resources/adversaries.json new file mode 100644 index 0000000..8f35e49 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/Resources/adversaries.json @@ -0,0 +1,3946 @@ +[ + { + "atk": "+3", + "attack": "Claws", + "damage": "1d12+2 phy", + "description": "A horse-sized insect with digging claws and acidic blood.", + "difficulty": "14", + "experience": "Tremor Sense +2", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Burrower can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Earth Eruption - Action", + "text": "**Mark a Stress** to have the Burrower burst out of the ground. All creatures within Very Close range must succeed on an Agility Reaction Roll or be knocked over, making them _Vulnerable_ until they next act." + }, + { + "name": "Spit Acid - Action", + "text": "Make an attack against all targets in front of the Burrower within Close range. Targets the Burrower succeeds against take **2d6** physical damage and must mark an Armor Slot without receiving its benefits (they can still use armor to reduce the damage). If they can't mark an Armor Slot, they must mark an additional HP and you gain a Fear." + }, + { + "name": "Acid Bath - Reaction", + "text": "When the Burrower takes Severe damage, all creatures within Close range are bathed in their acidic blood, taking **1d10** physical damage. This splash covers the ground within Very Close range with blood, and all creatures other than the Burrower who move through it take **1d6** physical damage." + } + ], + "hp": "8", + "motives_and_tactics": "Burrow, drag away, feed, reposition", + "name": "Acid Burrower", + "range": "Very Close", + "stress": "3", + "thresholds": "8/15", + "tier": "1", + "type": "Solo" + }, + { + "atk": "+1", + "attack": "Claws", + "damage": "1d8+3 phy", + "description": "A large bear with thick fur and powerful claws.", + "difficulty": "14", + "experience": "Ambusher +3, Keen Senses +2", + "feature": [ + { + "name": "Overwhelming Force - Passive", + "text": "Targets who mark HP from the Bear's standard attack are knocked back to Very Close range." + }, + { + "name": "Bite - Action", + "text": "**Mark a Stress** to make an attack against a target within Melee range. On a success, deal **3d4+10** physical damage and the target is _Restrained_ until they break free with a successful Strength Roll." + }, + { + "name": "Momentum - Reaction", + "text": "When the Bear makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Climb, defend territory, pummel, track", + "name": "Bear", + "range": "Melee", + "stress": "2", + "thresholds": "9/17", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+1", + "attack": "Club", + "damage": "1d10+2 phy", + "description": "A massive humanoid who sees all sapient life as food.", + "difficulty": "13", + "experience": "Throw +2", + "feature": [ + { + "name": "Ramp Up - Passive", + "text": "You must **spend a Fear** to spotlight the Ogre. While spotlighted, they can make their standard attack against all targets within range." + }, + { + "name": "Bone Breaker - Passive", + "text": "The Ogre's attacks deal direct damage." + }, + { + "name": "Hail of Boulders - Action", + "text": "**Mark a Stress** to pick up heavy objects and throw them at all targets in front of the Ogre within Far range. Make an attack against these targets. Targets the Ogre succeeds against take **1d10+2** physical damage. If they succeed against more than one target, you gain a Fear." + }, + { + "name": "Rampaging Fury - Reaction", + "text": "When the Ogre marks 2 or more HP, they can rampage. Move the Ogre to a point within Close range and deal **2d6+3** direct physical damage to all targets in their path." + } + ], + "hp": "8", + "motives_and_tactics": "Bite off heads, feast, rip limbs, stomp, throw enemies", + "name": "Cave Ogre", + "range": "Very Close", + "stress": "3", + "thresholds": "8/15", + "tier": "1", + "type": "Solo" + }, + { + "atk": "+4", + "attack": "Fist Slam", + "damage": "1d20 phy", + "description": "A roughly humanoid being of stone and steel, assembled and animated by magic.", + "difficulty": "13", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Construct can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Weak Structure - Passive", + "text": "When the Construct marks HP from physical damage, they must mark an additional HP." + }, + { + "name": "Trample - Action", + "text": "**Mark a Stress** to make an attack against all targets in the Construct's path when they move. Targets the Construct succeeds against take **1d8** physical damage." + }, + { + "name": "Overload - Reaction", + "text": "Before rolling damage for the Construct's attack, you can **mark a Stress** to gain a +10 bonus to the damage roll. The Construct can then take the spotlight again." + }, + { + "name": "Death Quake - Reaction", + "text": "When the Construct marks their last HP, the magic powering them ruptures in an explosion of force. Make an attack with advantage against all targets within Very Close range. Targets the Construct succeeds against take **1d12+2** magic damage." + } + ], + "hp": "9", + "motives_and_tactics": "Destroy environment, serve creator, smash target, trample groups", + "name": "Construct", + "range": "Melee", + "stress": "4", + "thresholds": "7/15", + "tier": "1", + "type": "Solo" + }, + { + "atk": "-4", + "attack": "Daggers", + "damage": "1d4+2 phy", + "description": "An ambitious and ostentatiously dressed socialite.", + "difficulty": "12", + "experience": "Socialite +3", + "feature": [ + { + "name": "Mockery - Action", + "text": "**Mark a Stress** to say something mocking and force a target within Close range to make a Presence Reaction Roll (14) to see if they can save face. On a failure, the target must mark 2 Stress and is _Vulnerable_ until the scene ends." + }, + { + "name": "Scapegoat - Action", + "text": "**Spend a Fear** and target a PC. The Courtier convinces a crowd or prominent individual that the target is the cause of their current conflict or misfortune." + } + ], + "hp": "3", + "motives_and_tactics": "Discredit, gain favor, maneuver, scheme", + "name": "Courtier", + "range": "Melee", + "stress": "4", + "thresholds": "4/8", + "tier": "1", + "type": "Social" + }, + { + "atk": "+2", + "attack": "Vines", + "damage": "1d8+3 phy", + "description": "A burly vegetable-person with grasping vines.", + "difficulty": "10", + "experience": "Huge +3", + "feature": [ + { + "name": "Ground Slam - Action", + "text": "Slam the ground, knocking all targets within Very Close range back to Far range. Each target knocked back this way must mark a Stress." + }, + { + "name": "Grab and Drag - Action", + "text": "Make an attack against a target within Close range. On a success, **spend a Fear** to pull them into Melee range, deal **1d6+2** physical damage, and _Restrain_ them until the Defender takes Severe damage." + } + ], + "hp": "7", + "motives_and_tactics": "Ambush, grab, protect, pummel", + "name": "Deeproot Defender", + "range": "Close", + "stress": "3", + "thresholds": "8/14", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+2", + "attack": "Claws", + "damage": "1d6+2 phy", + "description": "A large wolf with menacing teeth, seldom encountered alone.", + "difficulty": "12", + "experience": "Keen Senses +3", + "feature": [ + { + "name": "Pack Tactics - Passive", + "text": "If the Wolf makes a successful standard attack and another Dire Wolf is within Melee range of the target, deal **1d6+5** physical damage instead of their standard damage and you gain a Fear." + }, + { + "name": "Hobbling Strike - Action", + "text": "**Mark a Stress** to make an attack against a target within Melee range. On a success, deal **3d4+10** direct physical damage and make them _Vulnerable_ until they clear at least 1 HP." + } + ], + "hp": "4", + "motives_and_tactics": "Defend territory, harry, protect pack, surround, trail", + "name": "Dire Wolf", + "range": "Melee", + "stress": "3", + "thresholds": "5/9", + "tier": "1", + "type": "Skulk" + }, + { + "atk": "-2", + "attack": "Proboscis", + "damage": "1d8+3 phy", + "description": "Dozens of fist-sized mosquitoes, flying together for protection.", + "difficulty": "10", + "experience": "Camouflage +2", + "feature": [ + { + "name": "Horde (1d4+1) - Passive", + "text": "When the Mosquitoes have marked half or more of their HP, their standard attack deals **1d4+1** physical damage instead." + }, + { + "name": "Flying - Passive", + "text": "While flying, the Mosquitoes have a +2 bonus to their Difficulty." + }, + { + "name": "Bloodsucker - Reaction", + "text": "When the Mosquitoes' attack causes a target to mark HP, you can **mark a Stress** to force the target to mark an additional HP." + } + ], + "hp": "6", + "motives_and_tactics": "Fly away, harass, steal blood", + "name": "Giant Mosquitoes", + "range": "Melee", + "stress": "3", + "thresholds": "5/9", + "tier": "1", + "type": "Horde (5/HP)" + }, + { + "atk": "-4", + "attack": "Claws", + "damage": "1 phy", + "description": "A cat-sized rodent skilled at scavenging and survival.", + "difficulty": "10", + "experience": "Keen Senses +3", + "feature": [ + { + "name": "Minion (3) - Passive", + "text": "The Rat is defeated when they take any damage. For every 3 damage a PC deals to the Rat, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Giant Rats within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 1 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Burrow, hunger, scavenge, wear down", + "name": "Giant Rat", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+1", + "attack": "Pincers", + "damage": "1d12+2 phy", + "description": "A human-sized arachnid with tearing claws and a stinging tail.", + "difficulty": "13", + "experience": "Camouflage +2", + "feature": [ + { + "name": "Double Strike - Action", + "text": "**Mark a Stress** to make a standard attack against two targets within Melee range." + }, + { + "name": "Venomous Stinger - Action", + "text": "Make an attack against a target within Very Close range. On a success, **spend a Fear** to deal **1d4+4** physical damage and _Poison_ them until their next rest or they succeed on a Knowledge Roll (16). While _Poisoned_, the target must roll a **d6** before they make an action roll. On a result of 4 or lower, they must mark a Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Scorpion makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Ambush, feed, grapple, poison", + "name": "Giant Scorpion", + "range": "Melee", + "stress": "3", + "thresholds": "7/13", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+2", + "attack": "Glass Fangs", + "damage": "1d8+2 phy", + "description": "A clear serpent with a massive head that leaves behind a glass shard trail wherever they go.", + "difficulty": "14", + "feature": [ + { + "name": "Armor-Shredding Shards - Passive", + "text": "After a successful attack against the Snake within Melee range, the attacker must mark an Armor Slot. If they can't mark an Armor Slot, they must mark an HP." + }, + { + "name": "Spinning Serpent - Action", + "text": "**Mark a Stress** to make an attack against all targets within Very Close range. Targets the Snake succeeds against take **1d6+1** physical damage." + }, + { + "name": "Spitter - Action", + "text": "**Spend a Fear** to introduce a **d6** Spitter Die. When the Snake is in the spotlight, roll this die. On a result of 5 or higher, all targets in front of the Snake within Far range must succeed on an Agility Reaction Roll or take **1d4** physical damage. The Snake can take the spotlight a second time this GM turn." + } + ], + "hp": "5", + "motives_and_tactics": "Climb, feed, keep distance, scare", + "name": "Glass Snake", + "range": "Very Close", + "stress": "3", + "thresholds": "6/10", + "tier": "1", + "type": "Standard" + }, + { + "atk": "+1", + "attack": "Javelin", + "damage": "1d6+2 phy", + "description": "A nimble fighter armed with javelins.", + "difficulty": "12", + "experience": "Camouflage +2", + "feature": [ + { + "name": "Maintain Distance - Passive", + "text": "After making a standard attack, the Harrier can move anywhere within Far range." + }, + { + "name": "Fall Back - Reaction", + "text": "When a creature moves into Melee range to make an attack, you can **mark a Stress** before the attack roll to move anywhere within Close range and make an attack against that creature. On a success, deal **1d10+2** physical damage." + } + ], + "hp": "3", + "motives_and_tactics": "Flank, harry, kite, profit", + "name": "Harrier", + "range": "Close", + "stress": "3", + "thresholds": "5/9", + "tier": "1", + "type": "Standard" + }, + { + "atk": "+1", + "attack": "Longbow", + "damage": "1d8+3 phy", + "description": "A tall guard bearing a longbow and quiver with arrows fletched in the settlement's colors.", + "difficulty": "10", + "experience": "Local Knowledge +3", + "feature": [ + { + "name": "Hobbling Shot - Action", + "text": "Make an attack against a target within Far range. On a success, **mark a Stress** to deal **1d12+3** physical damage. If the target marks HP from this attack, they have disadvantage on Agility Rolls until they clear at least 1 HP." + } + ], + "hp": "3", + "motives_and_tactics": "Arrest, close gates, make it through the day, pin down", + "name": "Archer Guard", + "range": "Far", + "stress": "2", + "thresholds": "4/8", + "tier": "1", + "type": "Ranged" + }, + { + "atk": "+1", + "attack": "Longsword", + "damage": "1d6+1 phy", + "description": "An armored guard bearing a sword and shield painted in the settlement's colors.", + "difficulty": "12", + "experience": "Local Knowledge +3", + "feature": [ + { + "name": "Shield Wall - Passive", + "text": "A creature who tries to move within Very Close range of the Guard must succeed on an Agility Roll. If additional Bladed Guards are standing in a line alongside the first, and each is within Melee range of another guard in the line, the Difficulty increases by the total number of guards in that line." + }, + { + "name": "Detain - Action", + "text": "Make an attack against a target within Very Close range. On a success, **mark a Stress** to _Restrain_ the target until they break free with a successful attack, Finesse Roll, or Strength Roll." + } + ], + "hp": "5", + "motives_and_tactics": "Arrest, close gates, make it through the day, pin down", + "name": "Bladed Guard", + "range": "Melee", + "stress": "2", + "thresholds": "5/9", + "tier": "1", + "type": "Standard" + }, + { + "atk": "+4", + "attack": "Mace", + "damage": "1d10+4 phy", + "description": "A seasoned guard with a mace, a whistle, and a bellowing voice.", + "difficulty": "15", + "experience": "Commander +2, Local Knowledge +2", + "feature": [ + { + "name": "Rally Guards - Action", + "text": "**Spend 2 Fear** to spotlight the Head Guard and up to **2d4** allies within Far range." + }, + { + "name": "On My Signal - Reaction: Countdown (5)", + "text": "When the Head Guard is in the spotlight for the first time, activate the countdown. It ticks down when a PC makes an attack roll. When it triggers, all Archer Guards within Far range make a standard attack with advantage against the nearest target within their range. If any attacks succeed on the same target, combine their damage." + }, + { + "name": "Momentum - Reaction", + "text": "When the Head Guard makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Arrest, close gates, pin down, seek glory", + "name": "Head Guard", + "range": "Melee", + "stress": "3", + "thresholds": "7/13", + "tier": "1", + "type": "Leader" + }, + { + "atk": "+1", + "attack": "Daggers", + "damage": "1d8+1 phy", + "description": "A cunning criminal in a cloak bearing one of the gang's iconic knives.", + "difficulty": "12", + "experience": "Thief +2", + "feature": [ + { + "name": "Climber - Passive", + "text": "The Bandit climbs just as easily as they run." + }, + { + "name": "From Above - Passive", + "text": "When the Bandit succeeds on a standard attack from above a target, they deal **1d10+1** physical damage instead of their standard damage." + } + ], + "hp": "5", + "motives_and_tactics": "Escape, profit, steal, throw smoke", + "name": "Jagged Knife Bandit", + "range": "Melee", + "stress": "3", + "thresholds": "8/14", + "tier": "1", + "type": "Standard" + }, + { + "atk": "+2", + "attack": "Staff", + "damage": "1d6+2 mag", + "description": "A staff-wielding bandit in a cloak adorned with magical paraphernalia, using curses to vex their foes.", + "difficulty": "13", + "experience": "Magical Knowledge +2", + "feature": [ + { + "name": "Curse - Action", + "text": "Choose a target within Far range and temporarily _Curse_ them. While the target is _Cursed_, you can **mark a Stress** when that target rolls with Hope to make the roll be with Fear instead." + }, + { + "name": "Chaotic Flux - Action", + "text": "Make an attack against up to three targets within Very Close range. **Mark a Stress** to deal **2d6+3** magic damage to targets the Hexer succeeded against." + } + ], + "hp": "4", + "motives_and_tactics": "Command, hex, profit", + "name": "Jagged Knife Hexer", + "range": "Far", + "stress": "4", + "thresholds": "5/9", + "tier": "1", + "type": "Support" + }, + { + "atk": "-3", + "attack": "Club", + "damage": "1d4+6 phy", + "description": "An imposing brawler carrying a large club.", + "difficulty": "12", + "experience": "Thief +2, Unveiled Threats +3", + "feature": [ + { + "name": "I've Got 'Em - Passive", + "text": "Creatures _Restrained_ by the Kneebreaker take double damage from attacks by other adversaries." + }, + { + "name": "Hold Them Down - Action", + "text": "Make an attack against a target within Melee range. On a success, the target takes no damage but is _Restrained_ and _Vulnerable_. The target can break free, clearing both conditions, with a successful Strength Roll or is freed automatically if the Kneebreaker takes Major or greater damage." + } + ], + "hp": "7", + "motives_and_tactics": "Grapple, intimidate, profit, steal", + "name": "Jagged Knife Kneebreaker", + "range": "Melee", + "stress": "4", + "thresholds": "7/14", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "-2", + "attack": "Daggers", + "damage": "2 phy", + "description": "A thief with simple clothes and small daggers, eager to prove themselves.", + "difficulty": "9", + "experience": "Thief +2", + "feature": [ + { + "name": "Minion (3) - Passive", + "text": "The Lackey is defeated when they take any damage. For every 3 damage a PC deals to the Lackey, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Jagged Knife Lackeys within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Escape, profit, throw smoke", + "name": "Jagged Knife Lackey", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+2", + "attack": "Javelin", + "damage": "1d8+3 phy", + "description": "A seasoned bandit in quality leathers with a strong voice and cunning eyes.", + "difficulty": "13", + "experience": "Local Knowledge +2", + "feature": [ + { + "name": "Tactician - Action", + "text": "When you spotlight the Lieutenant, **mark a Stress** to also spotlight two allies within Close range." + }, + { + "name": "More Where That Came From - Action", + "text": "Summon three Jagged Knife Lackeys, who appear at Far range." + }, + { + "name": "Coup de Grace - Action", + "text": "**Spend a Fear** to make an attack against a _Vulnerable_ target within Close range. On a success, deal **2d6+12** physical damage and the target must mark a Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Lieutenant makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Bully, command, profit, reinforce", + "name": "Jagged Knife Lieutenant", + "range": "Close", + "stress": "3", + "thresholds": "7/14", + "tier": "1", + "type": "Leader" + }, + { + "atk": "+1", + "attack": "Daggers", + "damage": "1d4+4 phy", + "description": "A nimble scoundrel bearing a wicked knife and utilizing shadow magic to isolate targets.", + "difficulty": "12", + "experience": "Intrusion +3", + "feature": [ + { + "name": "Backstab - Passive", + "text": "When the Shadow succeeds on a standard attack that has advantage, they deal **1d6+6** physical damage instead of their standard damage." + }, + { + "name": "Cloaked - Action", + "text": "Become _Hidden_ until after the Shadow's next attack. Attacks made while _Hidden_ from this feature have advantage." + } + ], + "hp": "3", + "motives_and_tactics": "Ambush, conceal, divide, profit", + "name": "Jagged Knife Shadow", + "range": "Melee", + "stress": "3", + "thresholds": "4/8", + "tier": "1", + "type": "Skulk" + }, + { + "atk": "-1", + "attack": "Shortbow", + "damage": "1d10+2 phy", + "description": "A lanky bandit striking from cover with a shortbow.", + "difficulty": "13", + "experience": "Stealth +2", + "feature": [ + { + "name": "Unseen Strike - Passive", + "text": "If the Sniper is _Hidden_ when they make a successful standard attack against a target, they deal **1d10+4** physical damage instead of their standard damage." + } + ], + "hp": "3", + "motives_and_tactics": "Ambush, hide, profit, reposition", + "name": "Jagged Knife Sniper", + "range": "Far", + "stress": "2", + "thresholds": "4/7", + "tier": "1", + "type": "Ranged" + }, + { + "atk": "-4", + "attack": "Club", + "damage": "1d4+1 phy", + "description": "A finely dressed trader with a keen eye for financial gain.", + "difficulty": "12", + "experience": "Shrewd Negotiator +3", + "feature": [ + { + "name": "Preferential Treatment - Passive", + "text": "A PC who succeeds on a Presence Roll against the Merchant gains a discount on purchases. A PC who fails on a Presence Roll against the Merchant must pay more and has disadvantage on future Presence Rolls against the Merchant." + }, + { + "name": "The Runaround - Passive", + "text": "When a PC rolls a 14 or lower on a Presence Roll made against the Merchant, they must mark a Stress." + } + ], + "hp": "3", + "motives_and_tactics": "Buy low and sell high, create demand, inflate prices, seek profit", + "name": "Merchant", + "range": "Melee", + "stress": "3", + "thresholds": "4/8", + "tier": "1", + "type": "Social" + }, + { + "atk": "+3", + "attack": "Warp Blast", + "damage": "1d12+6 mag", + "description": "A coruscating mass of uncontrollable magic.", + "difficulty": "14", + "feature": [ + { + "name": "Arcane Form - Passive", + "text": "The Elemental is resistant to magic damage." + }, + { + "name": "Sickening Flux - Action", + "text": "**Mark a HP** to force all targets within Close range to mark a Stress and become _Vulnerable_ until their next rest or they clear a HP." + }, + { + "name": "Remake Reality - Action", + "text": "**Spend a Fear** to transform the area within Very Close range into a different biome. All targets within this area take **2d6+3** direct magic damage." + }, + { + "name": "Magical reflection - Reaction", + "text": "When the Elemental takes damage from an attack within Close range, deal an amount of damage to the attacker equal to half the damage they dealt." + }, + { + "name": "Momentum - Reaction", + "text": "When the Elemental makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Confound, destabilize, transmogrify", + "name": "Minor Chaos Elemental", + "range": "Close", + "stress": "3", + "thresholds": "7/14", + "tier": "1", + "type": "Solo" + }, + { + "atk": "+3", + "attack": "Elemental Blast", + "damage": "1d10+4 mag", + "description": "A living flame the size of a large bonfire.", + "difficulty": "13", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Elemental can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Scorched Earth - Action", + "text": "**Mark a Stress** to choose a point within Far range. The ground within Very Close range of that point immediately bursts into flames. All creatures within this area must make an Agility Reaction Roll. Targets who fail take **2d8** magic damage from the flames. Targets who succeed take half damage." + }, + { + "name": "Explosion - Action", + "text": "**Spend a Fear** to erupt in a fiery explosion. Make an attack against all targets within Close range. Targets the Elemental succeeds against take **1d8** magic damage and are knocked back to Far range." + }, + { + "name": "Consume Kindling - Reaction", + "text": "Three times per scene, when the Elemental moves onto objects that are highly flammable, consume them to clear a HP or a Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Elemental makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "9", + "motives_and_tactics": "Encircle enemies, grow in size, intimidate, start fires", + "name": "Minor Fire Elemental", + "range": "Far", + "stress": "3", + "thresholds": "7/15", + "tier": "1", + "type": "Solo" + }, + { + "atk": "+3", + "attack": "Claws", + "damage": "1d8+6 phy", + "description": "A crimson-hued creature from the Circles Below, consumed by rage against all mortals.", + "difficulty": "14", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Demon can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "All Must Fall - Passive", + "text": "When a PC rolls a failure with Fear while within Close range of the Demon, they lose a Hope." + }, + { + "name": "Hellfire - Action", + "text": "**Spend a Fear** to rain down hellfire within Far range. All targets within the area must make an Agility Reaction Roll. Targets who fail take **1d20+3** magic damage. Targets who succeed take half damage." + }, + { + "name": "Reaper - Reaction", + "text": "Before rolling damage for the Demon's attack, you can **mark a Stress** to gain a bonus to the damage roll equal to the Demon's current number of marked HP." + }, + { + "name": "Momentum - Reaction", + "text": "When the Demon makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "8", + "motives_and_tactics": "Act erratically, corral targets, relish pain, torment", + "name": "Minor Demon", + "range": "Melee", + "stress": "4", + "thresholds": "8/15", + "tier": "1", + "type": "Solo" + }, + { + "atk": "-2", + "attack": "Clawed Branch", + "damage": "4 phy", + "description": "An ambulatory sapling rising up to defend their forest.", + "difficulty": "10", + "feature": [ + { + "name": "Minion (5) - Passive", + "text": "The Treant is defeated when they take any damage. For every 5 damage a PC deals to the Treant, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Minor Treants within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 4 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Crush, overwhelm, protect", + "name": "Minor Treant", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+1", + "attack": "Ooze Appendage", + "damage": "1d6+1 mag", + "description": "A moving mound of translucent green slime.", + "difficulty": "8", + "experience": "Camouflage +3", + "feature": [ + { + "name": "Slow - Passive", + "text": "When you spotlight the Ooze and they don't have a token on their stat block, they can't act yet. Place a token on their stat block and describe what they're preparing to do. When you spotlight the Ooze and they have a token on their stat block, clear the token and they can act." + }, + { + "name": "Acidic Form - Passive", + "text": "When the Ooze makes a successful attack, the target must mark an Armor Slot without receiving its benefits (they can still use armor to reduce the damage). If they can't mark an Armor Slot, they must mark an additional HP." + }, + { + "name": "Envelop - Action", + "text": "Make a standard attack against a target within Melee range. On a success, the Ooze envelops them and the target must mark 2 Stress. The target must mark an additional Stress when they make an action roll. If the Ooze takes Severe damage, the target is freed." + }, + { + "name": "Split - Reaction", + "text": "When the Ooze has 3 or more HP marked, you can **spend a Fear** to split them into two Tiny Green Oozes (with no marked HP or Stress). Immediately spotlight both of them." + } + ], + "hp": "5", + "motives_and_tactics": "Camouflage, consume and multiply, creep up, envelop", + "name": "Green Ooze", + "range": "Melee", + "stress": "2", + "thresholds": "5/10", + "tier": "1", + "type": "Skulk" + }, + { + "atk": "-1", + "attack": "Ooze Appendage", + "damage": "1d4+1 mag", + "description": "A small moving mound of translucent green slime.", + "difficulty": "14", + "feature": [ + { + "name": "Acidic Form - Passive", + "text": "When the Ooze makes a successful attack, the target must mark an Armor Slot without receiving its benefits (they can still use armor to reduce the damage). If they can't mark an Armor Slot, they must mark an additional HP." + } + ], + "hp": "2", + "motives_and_tactics": "Camouflage, creep up", + "name": "Tiny Green Ooze", + "range": "Melee", + "stress": "1", + "thresholds": "4/None", + "tier": "1", + "type": "Skulk" + }, + { + "atk": "+1", + "attack": "Ooze Appendage", + "damage": "1d8+3 mag", + "description": "A moving mound of translucent flaming red slime.", + "difficulty": "10", + "experience": "Camouflage +3", + "feature": [ + { + "name": "Creeping Fire - Passive", + "text": "The Ooze can only move within Very Close range as their normal movement. They light any flammable object they touch on fire." + }, + { + "name": "Ignite - Action", + "text": "Make an attack against a target within Very Close range. On a success, the target takes **1d8** magic damage and is _Ignited_ until they're extinguished with a successful Finesse Roll (14). While _Ignited_, the target takes **1d4** magic damage when they make an action roll." + }, + { + "name": "Split - Reaction", + "text": "When the Ooze has 3 or more HP marked, you can **spend a Fear** to split them into two Tiny Red Oozes (with no marked HP or Stress). Immediately spotlight both of them." + } + ], + "hp": "5", + "motives_and_tactics": "Camouflage, consume and multiply, ignite, start fires", + "name": "Red Ooze", + "range": "Melee", + "stress": "3", + "thresholds": "6/11", + "tier": "1", + "type": "Skulk" + }, + { + "atk": "-1", + "attack": "Ooze Appendage", + "damage": "1d4+2 mag", + "description": "A small moving mound of translucent flaming red slime", + "difficulty": "11", + "feature": [ + { + "name": "Burning - Reaction", + "text": "When a creature within Melee range deals damage to the Ooze, they take **1d6** direct magic damage." + } + ], + "hp": "2", + "motives_and_tactics": "Blaze, Camouflage", + "name": "Tiny Red Ooze", + "range": "Melee", + "stress": "1", + "thresholds": "5/None", + "tier": "1", + "type": "Skulk" + }, + { + "atk": "-3", + "attack": "Rapier", + "damage": "1d6+1 phy", + "description": "A richly dressed and adorned aristocrat brimming with hubris.", + "difficulty": "14", + "experience": "Aristocrat +3", + "feature": [ + { + "name": "My Land, My Rules - Passive", + "text": "All social actions made against the Noble on their land have disadvantage." + }, + { + "name": "Guards, Seize Them! - Action", + "text": "Once per scene, **mark a Stress** to summon **1d4** Bladed Guards, who appear at Far range to enforce the Noble's will." + }, + { + "name": "Exile - Action", + "text": "**Spend a Fear** and target a PC. The Noble proclaims that the target and their allies are exiled from the noble's territory. While exiled, the target and their allies have disadvantage during social situations within the Noble's domain." + } + ], + "hp": "3", + "motives_and_tactics": "Abuse power, gather resources, mobilize minions", + "name": "Petty Noble", + "range": "Melee", + "stress": "5", + "thresholds": "6/10", + "tier": "1", + "type": "Social" + }, + { + "atk": "+4", + "attack": "Cutlass", + "damage": "1d12+2 phy", + "description": "A charismatic sea dog with an impressive hat, eager to raid and plunder.", + "difficulty": "14", + "experience": "Commander +2, Sailor +3", + "feature": [ + { + "name": "Swashbuckler - Passive", + "text": "When the Captain marks 2 or fewer HP from an attack within Melee range, the attacker must mark a Stress." + }, + { + "name": "Reinforcements - Action", + "text": "Once per scene, **mark a Stress** to summon a Pirate Raiders Horde, which appears at Far range." + }, + { + "name": "No Quarter - Action", + "text": "**Spend a Fear** to choose a target who has three or more Pirates within Melee range of them. The Captain leads the Pirates in hurling threats and promises of a watery grave. The target must make a Presence Reaction Roll. On a failure, the target marks **1d4+1** Stress. On a success, they must mark a Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Captain makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Command, make 'em walk the plank, plunder, raid", + "name": "Pirate Captain", + "range": "Melee", + "stress": "5", + "thresholds": "7/14", + "tier": "1", + "type": "Leader" + }, + { + "atk": "+1", + "attack": "Cutlass", + "damage": "1d8+2 phy", + "description": "Seafaring scoundrels moving in a ravaging pack.", + "difficulty": "12", + "experience": "Sailor +3", + "feature": [ + { + "name": "Horde (1d4+1) - Passive", + "text": "When the Raiders have marked half or more of their HP, their standard attack deals **1d4+1** physical damage instead." + }, + { + "name": "Swashbuckler - Passive", + "text": "When the Raiders mark 2 or fewer HP from an attack within Melee range, the attacker must mark a Stress." + } + ], + "hp": "4", + "motives_and_tactics": "Gang up, plunder, raid", + "name": "Pirate Raiders", + "range": "Melee", + "stress": "3", + "thresholds": "5/11", + "tier": "1", + "type": "Horde (3/HP)" + }, + { + "atk": "+1", + "attack": "Massive Fists", + "damage": "2d6 phy", + "description": "A thickly muscled and tattooed pirate with melon-sized fists.", + "difficulty": "13", + "experience": "Sailor +2", + "feature": [ + { + "name": "Swashbuckler - Passive", + "text": "When the Tough marks 2 or fewer HP from an attack within Melee range, the attacker must mark a Stress." + }, + { + "name": "Clear the Decks - Action", + "text": "Make an attack against a target within Very Close range. On a success, **mark a Stress** to move into Melee range of the target, dealing **3d4** physical damage and knocking the target back to Close range." + } + ], + "hp": "5", + "motives_and_tactics": "Plunder, raid, smash, terrorize", + "name": "Pirate Tough", + "range": "Melee", + "stress": "3", + "thresholds": "8/15", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+3", + "attack": "Longsword", + "damage": "3 phy", + "description": "An armed mercenary testing their luck.", + "difficulty": "10", + "feature": [ + { + "name": "Minion (4) - Passive", + "text": "The Sellsword is defeated when they take any damage. For every 4 damage a PC deals to the Sellsword, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Sellswords within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 3 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Charge, lacerate, overwhelm, profit", + "name": "Sellsword", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+2", + "attack": "Shortbow", + "damage": "1d8+1 phy", + "description": "A fragile skeleton with a shortbow and arrows.", + "difficulty": "9", + "feature": [ + { + "name": "Opportunist - Passive", + "text": "When two or more adversaries are within Very Close range of a creature, all damage the Archer deals to that creature is doubled." + }, + { + "name": "Deadly Shot - Action", + "text": "Make an attack against a _Vulnerable_ target within Far range. On a success, **mark a Stress** to deal **3d4+8** physical damage." + } + ], + "hp": "3", + "motives_and_tactics": "Perforate distracted targets, play dead, steal skin", + "name": "Skeleton Archer", + "range": "Far", + "stress": "2", + "thresholds": "4/7", + "tier": "1", + "type": "Ranged" + }, + { + "atk": "-1", + "attack": "Bone Claws", + "damage": "1 phy", + "description": "A clattering pile of bones.", + "difficulty": "8", + "feature": [ + { + "name": "Minion (4) - Passive", + "text": "The Dredge is defeated when they take any damage. For every 4 damage a PC deals to the Dredge, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Dredges within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 1 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Fall apart, overwhelm, play dead, steal skin", + "name": "Skeleton Dredge", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+2", + "attack": "Rusty Greatsword", + "damage": "1d10+2 phy", + "description": "A large armored skeleton with a huge blade.", + "difficulty": "13", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Knight makes a successful attack, all PCs within Close range lose a Hope and you gain a Fear." + }, + { + "name": "Cut to the Bone - Action", + "text": "**Mark a Stress** to make an attack against all targets within Very Close range. Targets the Knight succeeds against take **1d8+2** physical damage and must mark a Stress." + }, + { + "name": "Dig Two Graves - Reaction", + "text": "When the Knight is defeated, they make an attack against a target within Very Close range (prioritizing the creature who killed them). On a success, the target takes **1d4+8** physical damage and loses **1d4** Hope." + } + ], + "hp": "5", + "motives_and_tactics": "Cut down the living, steal skin, wreak havoc", + "name": "Skeleton Knight", + "range": "Melee", + "stress": "2", + "thresholds": "7/13", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+0", + "attack": "Sword", + "damage": "1d6+2 phy", + "description": "A dirt-covered skeleton armed with a rusted blade.", + "difficulty": "10", + "feature": [ + { + "name": "Only Bones - Passive", + "text": "The Warrior is resistant to physical damage." + }, + { + "name": "Won't Stay Dead - Reaction", + "text": "When the Warrior is defeated, you can spotlight them and roll a **d6**. On a result of 6, if there are other adversaries on the battlefield, the Warrior re-forms with no marked HP." + } + ], + "hp": "3", + "motives_and_tactics": "Feign death, gang up, steal skin", + "name": "Skeleton Warrior", + "range": "Melee", + "stress": "2", + "thresholds": "4/8", + "tier": "1", + "type": "Standard" + }, + { + "atk": "+3", + "attack": "Empowered Longsword", + "damage": "1d8+4 phy/mag", + "description": "A mercenary combining swordplay and magic to deadly effect.", + "difficulty": "14", + "experience": "Magical Knowledge +2", + "feature": [ + { + "name": "Arcane Steel - Passive", + "text": "Damage dealt by the Spellblade's standard attack is considered both physical and magic." + }, + { + "name": "Suppressing Blast - Action", + "text": "**Mark a Stress** and target a group within Far range. All targets must succeed on an Agility Reaction Roll or take **1d8+2** magic damage. You gain a Fear for each target who marked HP from this attack." + }, + { + "name": "Move as a Unit - Action", + "text": "**Spend 2 Fear** to spotlight up to five allies within Far range." + }, + { + "name": "Momentum - Reaction", + "text": "When the Spellblade makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Blast, command, endure", + "name": "Spellblade", + "range": "Melee", + "stress": "3", + "thresholds": "8/14", + "tier": "1", + "type": "Leader" + }, + { + "atk": "-3", + "attack": "Claws", + "damage": "1d8+2 phy", + "description": "A skittering mass of ordinary rodents moving as one like a ravenous wave.", + "difficulty": "10", + "feature": [ + { + "name": "Horde (1d4+1) - Passive", + "text": "When the Swarm has marked half or more of their HP, their standard attack deals **1d4+1** physical damage instead." + }, + { + "name": "In Your Face - Passive", + "text": "All targets within Melee range have disadvantage on attacks against targets other than the Swarm." + } + ], + "hp": "6", + "motives_and_tactics": "Consume, obscure, swarm", + "name": "Swarm of Rats", + "range": "Melee", + "stress": "2", + "thresholds": "6/10", + "tier": "1", + "type": "Horde (/HP)" + }, + { + "atk": "+0", + "attack": "Scythe", + "damage": "1d8+1 phy", + "description": "A faerie warrior adorned in armor made of leaves and bark.", + "difficulty": "11", + "experience": "Tracker +2", + "feature": [ + { + "name": "Pack Tactics - Passive", + "text": "If the Soldier makes a standard attack and another Sylvan Soldier is within Melee range of the target, deal **1d8+5** physical damage instead of their standard damage." + }, + { + "name": "Forest Control - Action", + "text": "**Spend a Fear** to pull down a tree within Close range. A creature hit by the tree must succeed on an Agility Reaction Roll (15) or take **1d10** physical damage." + }, + { + "name": "Blend In - Reaction", + "text": "When the Soldier makes a successful attack, you can **mark a Stress** to become _Hidden_ until the Soldier's next attack or a PC succeeds on an Instinct Roll (14) to find them." + } + ], + "hp": "4", + "motives_and_tactics": "Ambush, hide, overwhelm, protect, trail", + "name": "Sylvan Soldier", + "range": "Melee", + "stress": "2", + "thresholds": "6/11", + "tier": "1", + "type": "Standard" + }, + { + "atk": "+0", + "attack": "Thorns", + "damage": "1d6+3 phy", + "description": "A cluster of animate, blood-drinking tumbleweeds, each the size of a large gourd.", + "difficulty": "12", + "experience": "Camouflage +2", + "feature": [ + { + "name": "Horde (1d4+2) - Passive", + "text": "When the Swarm has marked half or more of their HP, their standard attack deals **1d4+2** physical damage instead." + }, + { + "name": "Crush - Action", + "text": "**Mark a Stress** to deal **2d6+8** direct physical damage to a target with 3 or more bramble tokens." + }, + { + "name": "Encumber - Reaction", + "text": "When the Swarm succeeds on an attack, give the target a bramble token. If a target has any bramble tokens, they are _Restrained_. If a target has 3 or more bramble tokens, they are also _Vulnerable_. All bramble tokens can be removed by succeeding on a Finesse Roll (12 + the number of bramble tokens) or dealing Major or greater damage to the Swarm. If bramble tokens are removed from a target using a Finesse Roll, a number of Tangle Bramble Minions spawn within Melee range equal to the number of tokens removed." + } + ], + "hp": "6", + "motives_and_tactics": "Digest, entangle, immobilize", + "name": "Tangle Bramble Swarm", + "range": "Melee", + "stress": "3", + "thresholds": "6/11", + "tier": "1", + "type": "Horde (3/HP)" + }, + { + "atk": "-1", + "attack": "Thorns", + "damage": "2 phy", + "description": "An animate, blood-drinking tumbleweed.", + "difficulty": "11", + "feature": [ + { + "name": "Minion (4) - Passive", + "text": "The Bramble is defeated when they take any damage. For every 4 damage a PC deals to the Tangle Bramble, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Tangle Brambles within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage." + }, + { + "name": "Drain and Multiply - Reaction", + "text": "When an attack from the Bramble causes a target to mark HP and there are three or more Tangle Bramble Minions within Close range, you can combine the Minions into a Tangle Bramble Swarm Horde. The Horde's HP is equal to the number of Minions combined." + } + ], + "hp": "1", + "motives_and_tactics": "Combine, drain, entangle", + "name": "Tangle Bramble", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+2", + "attack": "Claymore", + "damage": "1d12+2 phy", + "description": "A master-at-arms wielding a sword twice their size.", + "difficulty": "14", + "feature": [ + { + "name": "Goading Strike - Action", + "text": "Make a standard attack against a target. On a success, **mark a Stress** to _Taunt_ the target until their next successful attack. The next time the _Taunted_ target attacks, they have disadvantage against targets other than the Weaponmaster." + }, + { + "name": "Adrenaline Burst - Action", + "text": "Once per scene, **spend a Fear** to clear 2 HP and 2 Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Weaponmaster makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Act first, aim for the weakest, intimidate", + "name": "Weaponmaster", + "range": "Very Close", + "stress": "3", + "thresholds": "8/15", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+0", + "attack": "Scythe", + "damage": "1d8+5 phy", + "description": "An imperious tree-person leading their forest's defenses.", + "difficulty": "11", + "experience": "Leadership +3", + "feature": [ + { + "name": "Voice of the Forest - Action", + "text": "**Mark a Stress** to spotlight **1d4** allies within range of a target they can attack without moving. On a success, their attacks deal half damage." + }, + { + "name": "Thorny Cage - Action", + "text": "**Spend a Fear** to form a cage around a target within Very Close range and _Restrain_ them until they're freed with a successful Strength Roll. When a creature makes an action roll against the cage, they must mark a Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Dryad makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Command, nurture, prune the unwelcome", + "name": "Young Dryad", + "range": "Melee", + "stress": "2", + "thresholds": "6/11", + "tier": "1", + "type": "Leader" + }, + { + "atk": "+2", + "attack": "Slam", + "damage": "1d12+3 phy", + "description": "A large corpse, decay-bloated and angry.", + "difficulty": "10", + "experience": "Collateral Damage +2, Throw +4", + "feature": [ + { + "name": "Slow - Passive", + "text": "When you spotlight the Zombie and they don't have a token on their stat block, they can't act yet. Place a token on their stat block and describe what they're preparing to do. When you spotlight the Zombie and they have a token on their stat block, clear the token and they can act." + }, + { + "name": "Rend Asunder - Action", + "text": "Make a standard attack with advantage against a target the Zombie has _Restrained_. On a success, the attack deals direct damage." + }, + { + "name": "Rip and Tear - Reaction", + "text": "When the Zombies makes a successful standard attack, you can **mark a Stress** to temporarily _Restrain_ the target and force them to mark 2 Stress." + } + ], + "hp": "7", + "motives_and_tactics": "Crush, destroy, hail debris, slam", + "name": "Brawny Zombie", + "range": "Very Close", + "stress": "4", + "thresholds": "8/15", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+4", + "attack": "Too Many Arms", + "damage": "1d20 phy", + "description": "A towering gestalt of corpses moving as one, with torso-sized limbs and fists as large as a grown halfling.", + "difficulty": "13", + "experience": "Intimidation +2, Tear Things Apart +2", + "feature": [ + { + "name": "Destructible - Passive", + "text": "When the Zombie takes Major or greater damage, they mark an additional HP." + }, + { + "name": "Flailing Limbs - Passive", + "text": "When the Zombie makes a standard attack, they can attack all targets within Very Close range." + }, + { + "name": "Another for the Pile - Action", + "text": "When the Zombie is within Very Close range of a corpse, they can incorporate it into themselves, clearing a HP and a Stress." + }, + { + "name": "Tormented Screams - Action", + "text": "**Mark a Stress** to cause all PCs within Far range to make a Presence Reaction Roll (13). Targets who fail lose a Hope and you gain a Fear for each. Targets who succeed must mark a Stress." + } + ], + "hp": "10", + "motives_and_tactics": "Absorb corpses, flail, hunger, terrify", + "name": "Patchwork Zombie Hulk", + "range": "Very Close", + "stress": "3", + "thresholds": "8/15", + "tier": "1", + "type": "Solo" + }, + { + "atk": "-3", + "attack": "Bite", + "damage": "2 phy", + "description": "A decaying corpse ambling toward their prey.", + "difficulty": "8", + "feature": [ + { + "name": "Minion (3) - Passive", + "text": "The Zombie is defeated when they take any damage. For every 3 damage a PC deals to the Zombie, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Rotted Zombies within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Eat flesh, hunger, maul, surround", + "name": "Rotted Zombie", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+0", + "attack": "Bite", + "damage": "1d6+1 phy", + "description": "An animated corpse that moves shakily, driven only by hunger.", + "difficulty": "10", + "feature": [ + { + "name": "Too Many to Handle - Passive", + "text": "When the Zombie is within Melee range of a creature and at least one other Zombie is within Close range, all attacks against that creature have advantage." + }, + { + "name": "Horrifying - Passive", + "text": "Targets who mark HP from the Zombie's attacks must also mark a Stress." + } + ], + "hp": "4", + "motives_and_tactics": "Devour, hungry, mob enemy, shred flesh", + "name": "Shambling Zombie", + "range": "Melee", + "stress": "1", + "thresholds": "4/6", + "tier": "1", + "type": "Standard" + }, + { + "atk": "-1", + "attack": "Bite", + "damage": "1d10+2 phy", + "description": "A group of shambling corpses instinctively moving together.", + "difficulty": "8", + "feature": [ + { + "name": "Horde (1d4+2) - Passive", + "text": "When the Zombies have marked half or more of their HP, their standard attack deals **1d4+2** physical damage instead." + }, + { + "name": "Overwhelm - Reaction", + "text": "When the Zombies mark HP from an attack within Melee range, you can **mark a Stress** to make a standard attack against the attacker." + } + ], + "hp": "6", + "motives_and_tactics": "Consume flesh, hunger, maul", + "name": "Zombie Pack", + "range": "Melee", + "stress": "3", + "thresholds": "6/12", + "tier": "1", + "type": "Horde (2/HP)" + }, + { + "atk": "+0", + "attack": "Longbow", + "damage": "2d6+3 phy", + "description": "A group of trained archers bearing massive bows.", + "difficulty": "13", + "feature": [ + { + "name": "Horde (1d6+3) - Passive", + "text": "When the Squadron has marked half or more of their HP, their standard attack deals **1d6+3** physical damage instead." + }, + { + "name": "Focused Volley - Action", + "text": "**Spend a Fear** to target a point within Far range. Make an attack with advantage against all targets within Close range of that point. Targets the Squadron succeeds against take **1d10+4** physical damage." + }, + { + "name": "Suppressing Fire - Action", + "text": "**Mark a Stress** to target a point within Far range. Until the next roll with Fear, a creature who moves within Close range of that point must make an Agility Reaction Roll. On a failure, they take **2d6+3** physical damage. On a success, they take half damage." + } + ], + "hp": "4", + "motives_and_tactics": "Stick together, survive, volley fire", + "name": "Archer Squadron", + "range": "Far", + "stress": "3", + "thresholds": "8/16", + "tier": "2", + "type": "Horde (2/HP)" + }, + { + "atk": "-1", + "attack": "Thrown Dagger", + "damage": "4 phy", + "description": "A young trainee eager to prove themselves.", + "difficulty": "13", + "experience": "Intrusion +2", + "feature": [ + { + "name": "Minion (6) - Passive", + "text": "The Assassin is defeated when they take any damage. For every 6 damage a PC deals to the Assassin, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Apprentice Assassins within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 4 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Act reckless, kill, prove their worth, show off", + "name": "Apprentice Assassin", + "range": "Very Close", + "stress": "1", + "thresholds": "None", + "tier": "2", + "type": "Minion" + }, + { + "atk": "+3", + "attack": "Poisoned Throwing Dagger", + "damage": "2d8+1 phy", + "description": "A cunning scoundrel skilled in both poisons and ambushing.", + "difficulty": "14", + "experience": "Intrusion +2", + "feature": [ + { + "name": "Grindletooth Venom - Passive", + "text": "Targets who mark HP from the Assassin's attacks are _Vulnerable_ until they clear a HP." + }, + { + "name": "Out of Nowhere - Passive", + "text": "The Assassin has advantage on attacks if they are _Hidden_." + }, + { + "name": "Fumigation - Action", + "text": "Drop a smoke bomb that fills the air within Close range with smoke, _Dizzying_ all targets in this area. _Dizzied_ targets have disadvantage on their next action roll, then clear the condition." + } + ], + "hp": "4", + "motives_and_tactics": "Anticipate, get paid, kill, taint food and water", + "name": "Assassin Poisoner", + "range": "Close", + "stress": "4", + "thresholds": "8/16", + "tier": "2", + "type": "Skulk" + }, + { + "atk": "+5", + "attack": "Serrated Dagger", + "damage": "2d10+2 phy", + "description": "A seasoned killer with a threatening voice and a deadly blade.", + "difficulty": "15", + "experience": "Command +3, Intrusion +3", + "feature": [ + { + "name": "Won't See It Coming - Passive", + "text": "The Assassin deals direct damage while they're _Hidden_." + }, + { + "name": "Strike as One - Action", + "text": "**Mark a Stress** to spotlight a number of other Assassins equal to the Assassin's unmarked Stress." + }, + { + "name": "The Subtle Blade - Reaction", + "text": "When the Assassin successfully makes a standard attack against a _Vulnerable_ target, you can **spend a Fear** to deal Severe damage instead of their standard damage." + }, + { + "name": "Momentum - Reaction", + "text": "When the Assassin makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Ambush, get out alive, kill, prepare for all scenarios", + "name": "Master Assassin", + "range": "Close", + "stress": "5", + "thresholds": "12/25", + "tier": "2", + "type": "Leader" + }, + { + "atk": "+2", + "attack": "Slam", + "damage": "2d6+3 phy", + "description": "A cube-shaped construct with a different rune on each of their six sides.", + "difficulty": "15", + "experience": "Camouflage +2", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Box can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Randomized Tactics - Action", + "text": "**Mark a Stress** and roll a **d6**. The Box uses the corresponding move:\n\n- 1. _Mana Beam._ The Box fires a searing beam. Make an attack against a target within Far range. On a success, deal **2d10+2** magic damage.\n- 2. _Fire Jets._ The Box shoots into the air, spinning and releasing jets of flame. Make an attack against all targets within Close range. Targets the Box succeeds against take **2d8** physical damage.\n- 3. _Trample._ The Box rockets around erratically. Make an attack against all PCs within Close range. Targets the Box succeeds against take **1d6+5** physical damage and are _Vulnerable_ until their next roll with Hope.\n- 4. _Shocking Gas._ The Box sprays out a silver gas sparking with lightning. All targets within Close range must succeed on a Finesse Reaction Roll or mark 3 Stress.\n- 5. _Stunning Clap._ The Box leaps and their sides clap, creating a small sonic boom. All targets within Very Close range must succeed on a Strength Reaction Roll or become _Vulnerable_ until the cube is defeated.\n- 6. _Psionic Whine._ The Box releases a cluster of mechanical bees whose buzz rattles mortal minds. All targets within Close range must succeed on a Presence Reaction Roll or take **2d4+9** direct magic damage." + }, + { + "name": "Overcharge - Reaction", + "text": "Before rolling damage for the Box's attack, you can **mark a Stress** to add a **d6** to the damage roll. Additionally, you gain a Fear." + }, + { + "name": "Death Quake - Reaction", + "text": "When the Box marks their last HP, the magic powering them ruptures in an explosion of force. All targets within Close range must succeed on an Instinct Reaction Roll or take **2d8+1** magic damage." + } + ], + "hp": "8", + "motives_and_tactics": "Change tactics, trample foes, wait in disguise", + "name": "Battle Box", + "range": "Melee", + "stress": "6", + "thresholds": "10/20", + "tier": "2", + "type": "Solo" + }, + { + "atk": "+2", + "attack": "Energy Blast", + "damage": "2d8+3 mag", + "description": "A floating humanoid skull animated by scintillating magic.", + "difficulty": "15", + "feature": [ + { + "name": "Levitation - Passive", + "text": "The Skull levitates several feet off the ground and can't be _Restrained_." + }, + { + "name": "Wards - Passive", + "text": "The Skull is resistant to magic damage." + }, + { + "name": "Magic Burst - Action", + "text": "**Mark a Stress** to make an attack against all targets within Close range. Targets the Skull succeeds against take **2d6+4** magic damage." + }, + { + "name": "Siphon Magic - Action", + "text": "**Spend a Fear** to make an attack against a PC with a Spellcast trait within Very Close range. On a success, the target marks **1d4** Stress and the Skull clears that many Stress. Additionally, on a success, the Skull can immediately be spotlighted again." + } + ], + "hp": "5", + "motives_and_tactics": "Cackle, consume magic, serve creator", + "name": "Chaos Skull", + "range": "Close", + "stress": "4", + "thresholds": "8/16", + "tier": "2", + "type": "Ranged" + }, + { + "atk": "+0", + "attack": "Spears", + "damage": "6 phy", + "description": "A poorly trained civilian pressed into war.", + "difficulty": "12", + "feature": [ + { + "name": "Minion (6) - Passive", + "text": "The Conscript is defeated when they take any damage. For every 6 damage a PC deals to the Conscript, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Conscripts within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 6 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Follow orders, gang up, survive", + "name": "Conscript", + "range": "Very Close", + "stress": "1", + "thresholds": "None", + "tier": "2", + "type": "Minion" + }, + { + "atk": "-3", + "attack": "Dagger", + "damage": "1d4+3 phy", + "description": "An accomplished manipulator and master of the social arts.", + "difficulty": "13", + "experience": "Manipulation +3, Socialite +3", + "feature": [ + { + "name": "Searing Glance - Reaction", + "text": "When a PC within Close range makes a Presence Roll, you can **mark a Stress** to cast a gaze toward the aftermath. On the target's failure, they must mark 2 Stress and are _Vulnerable_ until the scene ends or they succeed on a social action against the Courtesan. On the target's success, they must mark a Stress." + } + ], + "hp": "3", + "motives_and_tactics": "Entice, maneuver, secure patrons", + "name": "Courtesan", + "range": "Melee", + "stress": "4", + "thresholds": "7/13", + "tier": "2", + "type": "Social" + }, + { + "atk": "+2", + "attack": "Rune-Covered Rod", + "damage": "2d4+3 mag", + "description": "An experienced mage wielding shadow and fear.", + "difficulty": "14", + "experience": "Fallen Lore +2, Rituals +2", + "feature": [ + { + "name": "Enervating Blast - Action", + "text": "**Spend a Fear** to make a standard attack against a target within range. On a success, the target must mark a Stress." + }, + { + "name": "Shroud of the Fallen - Action", + "text": "**Mark a Stress** to wrap an ally within Close range in a shroud of _Protection_ until the Adept marks their last HP. While _Protected_, the target has resistance to all damage." + }, + { + "name": "Shadow Shackles - Action", + "text": "**Spend a Fear** and choose a point within Far range. All targets within Close range of that point are _Restrained_ in smoky chains until they break free with a successful Strength or Instinct Roll. A target _Restrained_ by this feature must spend a Hope to make an action roll." + }, + { + "name": "Fear Is Fuel - Reaction", + "text": "Twice per scene, when a PC rolls a failure with Fear, clear a Stress." + } + ], + "hp": "4", + "motives_and_tactics": "Curry favor, hinder foes, uncover knowledge", + "name": "Cult Adept", + "range": "Far", + "stress": "6", + "thresholds": "9/18", + "tier": "2", + "type": "Support" + }, + { + "atk": "+2", + "attack": "Long Knife", + "damage": "2d8+4 phy", + "description": "A professional killer-turned-cultist.", + "difficulty": "15", + "feature": [ + { + "name": "Shadow's Embrace - Passive", + "text": "The Fang can climb and walk on vertical surfaces. **Mark a Stress** to move from one shadow to another within Far range." + }, + { + "name": "Pick Off the Straggler - Action", + "text": "**Mark a Stress** to cause a target within Melee range to make an Instinct Reaction Roll. On a failure, the target must mark 2 Stress and is teleported with the Fang to a shadow within Far range, making them temporarily _Vulnerable_. On a success, the target must mark a Stress." + } + ], + "hp": "4", + "motives_and_tactics": "Capture sacrifices, isolate prey, rise in the ranks", + "name": "Cult Fang", + "range": "Melee", + "stress": "4", + "thresholds": "9/17", + "tier": "2", + "type": "Skulk" + }, + { + "atk": "+0", + "attack": "Ritual Dagger", + "damage": "5 phy", + "description": "A low-ranking cultist in simple robes, eager to gain power.", + "difficulty": "13", + "feature": [ + { + "name": "Minion (6) - Passive", + "text": "The Initiate is defeated when they take any damage. For every 6 damage a PC deals to the Initiate, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Cult Initiates within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Follow orders, gain power, seek forbidden knowledge", + "name": "Cult Initiate", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "2", + "type": "Minion" + }, + { + "atk": "+0", + "attack": "Claws and Fangs", + "damage": "2d8+2 phy", + "description": "Unnatural hounds lit from within by hellfire.", + "difficulty": "15", + "experience": "Scent Tracking +3", + "feature": [ + { + "name": "Horde (2d4+1) - Passive", + "text": "When the Pack has marked half or more of their HP, their standard attack deals **2d4+1** physical damage instead." + }, + { + "name": "Dreadhowl - Action", + "text": "**Mark a Stress** to make all targets within Very Close range lose a Hope. If a target is not able to lose a Hope, they must instead mark 2 Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Pack makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Cause fear, consume flesh, please masters", + "name": "Demonic Hound Pack", + "range": "Melee", + "stress": "3", + "thresholds": "11/23", + "tier": "2", + "type": "Horde (1/HP)" + }, + { + "atk": "+0", + "attack": "Shocking Bite", + "damage": "2d6+4 phy", + "description": "A swarm of eels that encircle and electrocute.", + "difficulty": "14", + "feature": [ + { + "name": "Horde (2d4+1) - Passive", + "text": "When the Eels have marked half or more of their HP, their standard attack deals **2d4+1** physical damage instead." + }, + { + "name": "Paralyzing Shock - Action", + "text": "**Mark a Stress** to make a standard attack against all targets within Very Close range. You gain a Fear for each target that marks HP." + } + ], + "hp": "5", + "motives_and_tactics": "Avoid larger predators, shock prey, tear apart", + "name": "Electric Eels", + "range": "Melee", + "stress": "3", + "thresholds": "10/20", + "tier": "2", + "type": "Horde (/HP)" + }, + { + "atk": "+1", + "attack": "Spear", + "damage": "2d8+4 phy", + "description": "An armored squire or experienced commoner looking to advance.", + "difficulty": "15", + "feature": [ + { + "name": "Reinforce - Action", + "text": "**Mark a Stress** to move into Melee range of an ally and make a standard attack against a target within Very Close range. On a success, deal **2d10+2** physical damage and the ally can clear a Stress." + }, + { + "name": "Vassal's Loyalty - Reaction", + "text": "When the Soldier is within Very Close range of a knight or other noble who would take damage, you can **mark a Stress** to move into Melee range of them and take the damage instead." + } + ], + "hp": "4", + "motives_and_tactics": "Gain glory, keep order, make alliances", + "name": "Elite Soldier", + "range": "Very Close", + "stress": "3", + "thresholds": "9/18", + "tier": "2", + "type": "Standard" + }, + { + "atk": "+1", + "attack": "Bite and Claw", + "damage": "2d6+5 phy", + "description": "A magical necromantic experiment gone wrong, leaving them warped and ungainly.", + "difficulty": "13", + "experience": "Copycat +3", + "feature": [ + { + "name": "Warped Fortitude - Passive", + "text": "The Experiment is resistant to physical damage." + }, + { + "name": "Overwhelm - Passive", + "text": "When a target the Experiment attacks has other adversaries within Very Close range, the Experiment deals double damage." + }, + { + "name": "Lurching Lunge - Action", + "text": "**Mark a Stress** to spotlight the Experiment as an additional GM move instead of spending Fear." + } + ], + "hp": "3", + "motives_and_tactics": "Devour, hunt, track", + "name": "Failed Experiment", + "range": "Melee", + "stress": "3", + "thresholds": "12/23", + "tier": "2", + "type": "Standard" + }, + { + "atk": "+2", + "attack": "Longbow", + "damage": "2d8+4 phy", + "description": "A leather-clad warrior bearing a whip and massive bow.", + "difficulty": "16", + "experience": "Animal Handling +3", + "feature": [ + { + "name": "Two as One - Passive", + "text": "When the Beastmaster is spotlighted, you can also spotlight a Tier 1 animal adversary currently under their control." + }, + { + "name": "Pinning Strike - Action", + "text": "Make a standard attack against a target. On a success, you can **mark a Stress** to pin them to a nearby surface. The pinned target is _Restrained_ until they break free with a successful Finesse or Strength Roll." + }, + { + "name": "Deadly Companion - Action", + "text": "Twice per scene, summon a Bear, Dire Wolf, or similar Tier 1 animal adversary under the Beastmaster's control. The adversary appears at Close range and is immediately spotlighted." + } + ], + "hp": "6", + "motives_and_tactics": "Command, make a living, maneuver, pin down, protect companion animals", + "name": "Giant Beastmaster", + "range": "Far", + "stress": "5", + "thresholds": "12/24", + "tier": "2", + "type": "Leader" + }, + { + "atk": "+2", + "attack": "Warhammer", + "damage": "2d12+3 phy", + "description": "An especially muscular giant wielding a warhammer larger than a human.", + "difficulty": "15", + "experience": "Intrusion +2", + "feature": [ + { + "name": "Battering Ram - Action", + "text": "**Mark a Stress** to have the Brawler charge at an inanimate object within Close range they could feasibly smash (such as a wall, cart, or market stand) and destroy it. All targets within Very Close range of the object must succeed on an Agility Reaction Roll or take **2d4+3** physical damage from the shrapnel." + }, + { + "name": "Bloody Reprisal - Reaction", + "text": "When the Brawler marks 2 or more HP from an attack within Very Close range, you can make a standard attack against the attacker. On a success, the Brawler deals **2d6+15** physical damage instead of their standard damage." + }, + { + "name": "Momentum - Reaction", + "text": "When the Brawler makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Make a living, overwhelm, slam, topple", + "name": "Giant Brawler", + "range": "Very Close", + "stress": "4", + "thresholds": "14/28", + "tier": "2", + "type": "Bruiser" + }, + { + "atk": "+1", + "attack": "Warhammer", + "damage": "5 phy", + "description": "A giant fighter wearing borrowed armor.", + "difficulty": "13", + "feature": [ + { + "name": "Minion (7) - Passive", + "text": "The Recruit is defeated when they take any damage. For every 7 damage a PC deals to the Recruit, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Giant Recruits within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Batter, make a living, overwhelm, terrify", + "name": "Giant Recruit", + "range": "Very Close", + "stress": "2", + "thresholds": "None", + "tier": "2", + "type": "Minion" + }, + { + "atk": "+1", + "attack": "Claws and Beak", + "damage": "2d6+3 phy", + "description": "A giant bird of prey with blood-stained talons.", + "difficulty": "14", + "feature": [ + { + "name": "Flight - Passive", + "text": "While flying, the Eagle gains a +3 bonus to their Difficulty." + }, + { + "name": "Deadly Dive - Action", + "text": "**Mark a Stress** to attack a target within Far range. On a success, deal **2d10+2** physical damage and knock the target over, making them _Vulnerable_ until they next act." + }, + { + "name": "Take Off- Action", + "text": "Make an attack against a target within Very Close range. On a success, deal **2d4+3** physical damage and the target must succeed on an Agility Reaction Roll or become temporarily _Restrained_ within the Eagle's massive talons. If the target is _Restrained_, the Eagle immediately lifts into the air to Very Far range above the battlefield while holding them." + }, + { + "name": "Deadly Drop - Action", + "text": "While flying, the Eagle can drop a _Restrained_ target they are holding. When dropped, the target is no longer _Restrained_ but starts falling. If their fall isn't prevented during the PCs' next action, the target takes **2d20** physical damage when they land." + } + ], + "hp": "4", + "motives_and_tactics": "Hunt prey, stay mobile, strike decisively", + "name": "Giant Eagle", + "range": "Very Close", + "stress": "4", + "thresholds": "8/19", + "tier": "2", + "type": "Skulk" + }, + { + "atk": "+4", + "attack": "Sunsear Shortbow", + "damage": "2d20+3 mag", + "description": "A snake-headed, scaled humanoid with a gilded bow, enraged that their peace has been disturbed.", + "difficulty": "15", + "experience": "Stealth +3", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Gorgon can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Sunsear Arrows - Passive", + "text": "When the Gorgon makes a successful standard attack, the target _Glows_ until the end of the scene and can't become _Hidden_. Attack rolls made against a _Glowing_ target have advantage." + }, + { + "name": "Crown of Serpents - Action", + "text": "Make an attack roll against a target within Melee range using the Gorgon's protective snakes. On a success, **mark a Stress** to deal **2d10+4** physical damage and the target must mark a Stress." + }, + { + "name": "Petrifying Gaze - Reaction", + "text": "When the Gorgon takes damage from an attack within Close range, you can **spend a Fear** to force the attacker to make an Instinct Reaction Roll. On a failure, they begin to turn to stone, marking a HP and starting a Petrification Countdown (4). This countdown ticks down when the Gorgon is attacked. When it triggers, the target must make a death move. If the Gorgon is defeated, all petrification countdowns end." + }, + { + "name": "Momentum - Reaction", + "text": "When the Gorgon makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "9", + "motives_and_tactics": "Corner, hit-and-run, petrify, seek vengeance", + "name": "Gorgon", + "range": "Far", + "stress": "3", + "thresholds": "13/25", + "tier": "2", + "type": "Solo" + }, + { + "atk": "+3", + "attack": "Wing Slash", + "damage": "2d10+4 phy", + "description": "A horse-sized insect with iridescent scales and crystalline wings moving faster than the eye can see.", + "difficulty": "14", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Flickerfly can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Peerless Accuracy - Passive", + "text": "Before the Flickerfly makes an attack, roll a **d6**. On a result of 4 or higher, the target's Evasion is halved against this attack." + }, + { + "name": "Mind Dance - Action", + "text": "**Mark a Stress** to create a magically dazzling display that grapples the minds of nearby foes. All targets within Close range must make an Instinct Reaction Roll. For each target who failed, you gain a Fear and the Flickerfly learns one of the target's fears." + }, + { + "name": "Hallucinatory Breath - Reaction: Countdown (Loop 1d6)", + "text": "When the Flickerfly takes damage for the first time, activate the countdown. When it triggers, the Flickerfly breathes hallucinatory gas on all targets in front of them up to Far range. Targets must succeed on an Instinct Reaction Roll or be tormented by fearful hallucinations. Targets whose fears are known to the Flickerfly have disadvantage on this roll. Targets who fail must mark a Stress and lose a Hope." + } + ], + "hp": "10", + "motives_and_tactics": "Collect shiny things, hunt, swoop", + "name": "Juvenile Flickerfly", + "range": "Very Close", + "stress": "5", + "thresholds": "13/26", + "tier": "2", + "type": "Solo" + }, + { + "atk": "+4", + "attack": "Longsword", + "damage": "2d10+4 phy", + "description": "A decorated soldier with heavy armor and a powerful steed.", + "difficulty": "15", + "experience": "Ancient Knowledge +3, High Society +2, Tactics +2", + "feature": [ + { + "name": "Chevalier - Passive", + "text": "While the Knight is on a mount, they gain a +2 bonus to their Difficulty. When they take Severe damage, they're knocked from their mount and lose this benefit until they're next spotlighted." + }, + { + "name": "Heavily Armored - Passive", + "text": "When the Knight takes physical damage, reduce it by 3." + }, + { + "name": "Cavalry Charge - Action", + "text": "If the Knight is mounted, move up to Far range and make a standard attack against a target. On a success, deal **2d8+4** physical damage and the target must mark a Stress." + }, + { + "name": "For the Realm! - Action", + "text": "**Mark a Stress** to spotlight **1d4+1** allies. Attacks they make while spotlighted in this way deal half damage." + } + ], + "hp": "6", + "motives_and_tactics": "Run down, seek glory, show dominance", + "name": "Knight of the Realm", + "range": "Melee", + "stress": "4", + "thresholds": "13/26", + "tier": "2", + "type": "Leader" + }, + { + "atk": "+3", + "attack": "Backsword", + "damage": "2d8+3 phy", + "description": "A cunning thief with acrobatic skill and a flair for the dramatic.", + "difficulty": "14", + "experience": "Acrobatics +3", + "feature": [ + { + "name": "Quick Hands - Action", + "text": "Make an attack against a target within Melee range. On a success, deal **1d8+2** physical damage and the Thief steals one item or consumable from the target's inventory." + }, + { + "name": "Escape Plan - Action", + "text": "**Mark a Stress** to reveal a snare trap set anywhere on the battlefield by the Thief. All targets within Very Close range of the trap must succeed on an Agility Reaction Roll (13) or be pulled off their feet and suspended upside down. A target is _Restrained_ and _Vulnerable_ until they break free, ending both conditions, with a successful Finesse or Strength Roll (13)." + } + ], + "hp": "4", + "motives_and_tactics": "Evade, hide, pilfer, profit", + "name": "Masked Thief", + "range": "Melee", + "stress": "5", + "thresholds": "8/17", + "tier": "2", + "type": "Skulk" + }, + { + "atk": "-2", + "attack": "Rapier", + "damage": "1d6+2 phy", + "description": "An accomplished merchant with a large operation under their command.", + "difficulty": "15", + "experience": "Nobility +2, Trade +2", + "feature": [ + { + "name": "Everyone Has a Price - Action", + "text": "**Spend a Fear** to offer a target a dangerous bargain for something they want or need. If used on a PC, they must make a Presence Reaction Roll (17). On a failure, they must mark 2 Stress or take the deal." + }, + { + "name": "The Best Muscle Money Can Buy - Action", + "text": "Once per scene, **mark a Stress** to summon **1d4+1** Tier 1 adversaries, who appear at Far range, to enforce the Baron's will." + } + ], + "hp": "5", + "motives_and_tactics": "Abuse power, gather resources, mobilize minions", + "name": "Merchant Baron", + "range": "Melee", + "stress": "3", + "thresholds": "9/19", + "tier": "2", + "type": "Social" + }, + { + "atk": "+2", + "attack": "Battleaxe", + "damage": "2d8+5 phy", + "description": "A massive bull-headed firbolg with a quick temper.", + "difficulty": "16", + "experience": "Navigation +2", + "feature": [ + { + "name": "Ramp Up - Passive", + "text": "You must **spend a Fear** to spotlight the Minotaur. While spotlighted, they can make their standard attack against all targets within range." + }, + { + "name": "Charging Bull - Action", + "text": "**Mark a Stress** to charge through a group within Close range and make an attack against all targets in the Minotaur's path. Targets the Minotaur succeeds against take **2d6+8** physical damage and are knocked back to Very Far range. If a target is knocked into a solid object or another creature, they take an extra **1d6** damage (combine the damage)." + }, + { + "name": "Gore - Action", + "text": "Make an attack against a target within Very Close range, moving the Minotaur into Melee range of them. On a success, deal **2d8** direct physical damage." + } + ], + "hp": "7", + "motives_and_tactics": "Consume, gore, navigate, overpower, pursue", + "name": "Minotaur Wrecker", + "range": "Very Close", + "stress": "5", + "thresholds": "14/27", + "tier": "2", + "type": "Bruiser" + }, + { + "atk": "+5", + "attack": "Tear at Flesh", + "damage": "2d12+1 phy", + "description": "An undead figure wearing a heavy leather coat, with searching eyes and a casually cruel demeanor.", + "difficulty": "16", + "experience": "Bloodhound +3", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Hunter makes a successful attack, all PCs within Far range lose a Hope and you gain a Fear." + }, + { + "name": "Deathlock - Action", + "text": "**Spend a Fear** to curse a target within Very Close range with a necrotic _Deathlock_ until the end of the scene. Attacks made by the Hunter against a _Deathlocked_ target deal direct damage. The Hunter can only maintain one _Deathlock_ at a time." + }, + { + "name": "Inevitable Death - Action", + "text": "**Mark a Stress** to spotlight **1d4** allies. Attacks they make while spotlighted in this way deal half damage." + }, + { + "name": "Rampage - Reaction: Countdown (Loop 1d6)", + "text": "When the Hunter is in the spotlight for the first time, activate the countdown. When it triggers, move the Hunter in a straight line to a point within Far range and make an attack against all targets in their path. Targets the Hunter succeeds against take **2d8+2** physical damage." + } + ], + "hp": "6", + "motives_and_tactics": "Devour, hunt, track", + "name": "Mortal Hunter", + "range": "Very Close", + "stress": "4", + "thresholds": "15/27", + "tier": "2", + "type": "Leader" + }, + { + "atk": "-3", + "attack": "Wand", + "damage": "1d4+3 phy", + "description": "A high-ranking courtier with the ear of the local nobility.", + "difficulty": "14", + "experience": "Administration +3, Courtier +3", + "feature": [ + { + "name": "Devastating Retort - Passive", + "text": "A PC who rolls less than 17 on an action roll targeting the Advisor must mark a Stress." + }, + { + "name": "Bend Ears - Action", + "text": "**Mark a Stress** to influence an NPC within Melee range with whispered words. That target's opinion on one matter shifts toward the Advisor's preference unless it is in direct opposition to the target's motives." + }, + { + "name": "Scapegoat - Action", + "text": "**Spend a Fear** to convince a crowd or notable individual that one person or group is responsible for some problem facing the target. The target becomes hostile to the scapegoat until convinced of their innocence with a successful Presence Roll (17)." + } + ], + "hp": "3", + "motives_and_tactics": "Curry favor, manufacture evidence, scheme", + "name": "Royal Advisor", + "range": "Far", + "stress": "3", + "thresholds": "8/15", + "tier": "2", + "type": "Social" + }, + { + "atk": "+3", + "attack": "Sigil-laden Staff", + "damage": "2d12 mag", + "description": "A clandestine leader with a direct channel to the Fallen Gods.", + "difficulty": "16", + "experience": "Coercion +2, Fallen Lore +2", + "feature": [ + { + "name": "Seize Your Moment - Action", + "text": "**Spend 2 Fear** to spotlight **1d4** allies. Attacks they make while spotlighted in this way deal half damage." + }, + { + "name": "Our Master's Will - Reaction", + "text": "When you spotlight an ally within Far range, **mark a Stress** to gain a Fear." + }, + { + "name": "Summoning Ritual - Reaction: Countdown (6)", + "text": "When the Secret-Keeper is in the spotlight for the first time, activate the countdown. When they mark HP, tick down this countdown by the number of HP marked. When it triggers, summon a Minor Demon who appears at Close range." + }, + { + "name": "Fallen Hounds - Reaction", + "text": "Once per scene, when the Secret-Keeper marks 2 or more HP, you can **mark a Stress** to summon a Demonic Hound Pack, which appears at Close range and is immediately spotlighted." + } + ], + "hp": "7", + "motives_and_tactics": "Amass great power, plot, take command", + "name": "Secret-Keeper", + "range": "Far", + "stress": "4", + "thresholds": "13/26", + "tier": "2", + "type": "Leader" + }, + { + "atk": "+2", + "attack": "Toothy Maw", + "damage": "2d12+1 phy", + "description": "A large aquatic predator, always on the move.", + "difficulty": "14", + "experience": "Sense of Smell +3", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Shark makes a successful attack, all PCs within Far range lose a Hope and you gain a Fear." + }, + { + "name": "Rending Bite - Passive", + "text": "When the Shark makes a successful attack, the target must mark an Armor Slot without receiving its benefits (they can still use armor to reduce the damage). If they can't mark an Armor Slot, they must mark an additional HP." + }, + { + "name": "Blood in the Water - Reaction", + "text": "When a creature within Close range of the Shark marks HP from another creature's attack, you can **mark a Stress** to immediately spotlight the Shark, moving them into Melee range of the target and making a standard attack." + } + ], + "hp": "7", + "motives_and_tactics": "Find the blood, isolate prey, target the weak", + "name": "Shark", + "range": "Very Close", + "stress": "3", + "thresholds": "14/28", + "tier": "2", + "type": "Bruiser" + }, + { + "atk": "+2", + "attack": "Distended Jaw Bite", + "damage": "2d6+3 phy", + "description": "A half-fish person with shimmering scales and an irresistible voice.", + "difficulty": "14", + "experience": "Song Repertoire +3", + "feature": [ + { + "name": "Captive Audience - Passive", + "text": "If the Siren makes a standard attack against a target _Entranced_ by their song, the attack deals **2d10+1** damage instead of their standard damage." + }, + { + "name": "Enchanting Song - Action", + "text": "**Spend a Fear** to sing a song that affects all targets within Close range. Targets must succeed on an Instinct Reaction Roll or become _Entranced_ until they mark 2 Stress. Other Sirens within Close range of the target can **mark a Stress** to each add a +1 bonus to the Difficulty of the reaction roll. While _Entranced_, a target can't act and is _Vulnerable_." + } + ], + "hp": "5", + "motives_and_tactics": "Consume, lure prey, subdue with song", + "name": "Siren", + "range": "Melee", + "stress": "3", + "thresholds": "9/18", + "tier": "2", + "type": "Skulk" + }, + { + "atk": "+3", + "attack": "Longbow", + "damage": "2d10+2 phy", + "description": "A ghostly fighter with an ethereal bow, unable to move on while their charge is vulnerable.", + "difficulty": "13", + "experience": "Ancient Knowledge +2", + "feature": [ + { + "name": "Ghost - Passive", + "text": "The Archer has resistance to physical damage. **Mark a Stress** to move up to Close range through solid objects." + }, + { + "name": "Pick Your Target - Action", + "text": "**Spend a Fear** to make an attack within Far range against a PC who is within Very Close range of at least two other PCs. On a success, the target takes **2d8+12** physical damage." + } + ], + "hp": "3", + "motives_and_tactics": "Move through solid objects, stay out of the fray, rehash old battles", + "name": "Spectral Archer", + "range": "Far", + "stress": "3", + "thresholds": "6/14", + "tier": "2", + "type": "Ranged" + }, + { + "atk": "+3", + "attack": "Longbow", + "damage": "2d10+3 phy", + "description": "A ghostly commander leading their troops beyond death.", + "difficulty": "16", + "experience": "Ancient Knowledge +3", + "feature": [ + { + "name": "Ghost - Passive", + "text": "The Captain has resistance to physical damage. **Mark a Stress** to move up to Close range through solid objects." + }, + { + "name": "Unending Battle - Action", + "text": "**Spend 2 Fear** to return up to **1d4+1** defeated Spectral allies to the battle at the points where they first appeared (with no marked HP or Stress)." + }, + { + "name": "Hold Fast - Reaction", + "text": "When the Captain's Spectral allies are forced to make a reaction roll, you can **mark a Stress** to give those allies a +2 bonus to the roll." + }, + { + "name": "Momentum - Reaction", + "text": "When the Captain makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Move through solid objects, rally troops, rehash old battles", + "name": "Spectral Captain", + "range": "Far", + "stress": "4", + "thresholds": "13/26", + "tier": "2", + "type": "Leader" + }, + { + "atk": "+1", + "attack": "Spear", + "damage": "2d8+1 phy", + "description": "A ghostly fighter with spears and swords, anchored by duty.", + "difficulty": "15", + "experience": "Ancient Knowledge +2", + "feature": [ + { + "name": "Ghost - Passive", + "text": "The Guardian has resistance to physical damage. **Mark a Stress** to move up to Close range through solid objects." + }, + { + "name": "Grave Blade - Action", + "text": "**Spend a Fear** to make an attack against a target within Very Close range. On a success, deal **2d10+6** physical damage and the target must mark a Stress." + } + ], + "hp": "4", + "motives_and_tactics": "Move through solid objects, protect treasure, rehash old battles", + "name": "Spectral Guardian", + "range": "Very Close", + "stress": "3", + "thresholds": "7/15", + "tier": "2", + "type": "Standard" + }, + { + "atk": "-2", + "attack": "Dagger", + "damage": "2d6+3 phy", + "description": "A skilled espionage agent with a knack for being in the right place to overhear secrets.", + "difficulty": "15", + "experience": "Espionage +3", + "feature": [ + { + "name": "Gathering Secrets - Action", + "text": "**Spend a Fear** to describe how the Spy knows a secret about a PC in the scene." + }, + { + "name": "Fly on the Wall - Reaction", + "text": "When a PC or group is discussing something sensitive, you can **mark a Stress** to reveal that the Spy is present in the scene, observing them. If the Spy escapes the scene to report their findings, you gain **1d4** Fear." + } + ], + "hp": "4", + "motives_and_tactics": "Cut and run, disguise appearance, eavesdrop", + "name": "Spy", + "range": "Melee", + "stress": "3", + "thresholds": "8/17", + "tier": "2", + "type": "Social" + }, + { + "atk": "+3", + "attack": "Bite and Claws", + "damage": "2d8+6 phy", + "description": "A prowling hunter, like a slinking mountain lion, with a slate-gray stone body.", + "difficulty": "13", + "experience": "Stonesense +3", + "feature": [ + { + "name": "Stonestrider - Passive", + "text": "The Stonewraith can move through stone and earth as easily as air. While within stone or earth, they are _Hidden_ and immune to all damage." + }, + { + "name": "Rocky Ambush - Action", + "text": "While _Hidden_, **mark a Stress** to leap into Melee range with a target within Very Close range. The target must succeed on an Agility or Instinct Reaction Roll (15) or take **2d8** physical damage and become temporarily _Restrained_." + }, + { + "name": "Avalanche Roar - Action", + "text": "**Spend a Fear** to roar while within a cave and cause a cave-in. All targets within Close range must succeed on an Agility Reaction Roll (14) or take **2d10** physical damage. The rubble can be cleared with a Progress Countdown (8)." + }, + { + "name": "Momentum - Reaction", + "text": "When the Stonewraith makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Defend territory, isolate prey, stalk", + "name": "Stonewraith", + "range": "Melee", + "stress": "3", + "thresholds": "11/22", + "tier": "2", + "type": "Skulk" + }, + { + "atk": "+4", + "attack": "Staff", + "damage": "2d10+4 mag", + "description": "A battle-hardened mage trained in destructive magic.", + "difficulty": "16", + "experience": "Magical Knowledge +2, Strategize +2", + "feature": [ + { + "name": "Battle Teleport - Passive", + "text": "Before or after making a standard attack, you can **mark a Stress** to teleport to a location within Far range." + }, + { + "name": "Refresh Warding Sphere - Action", + "text": "**Mark a Stress** to refresh the Wizard's \"Warding Sphere\" reaction." + }, + { + "name": "Eruption - Action", + "text": "**Spend a Fear** and choose a point within Far range. A Very Close area around that point erupts into impassable terrain. All targets within that area must make an Agility Reaction Roll (14). Targets who fail take **2d10** physical damage and are thrown out of the area. Targets who succeed take half damage and aren't moved." + }, + { + "name": "Arcane Artillery - Action", + "text": "**Spend a Fear** to unleash a precise hail of magical blasts. All targets in the scene must make an Agility Reaction Roll. Targets who fail take **2d12** magic damage. Targets who succeed take half damage." + }, + { + "name": "Warding Sphere - Reaction", + "text": "When the Wizard takes damage from an attack within Close range, deal **2d6** magic damage to the attacker. This reaction can't be used again until the Wizard refreshes it with their \"Refresh Warding Sphere\" action." + } + ], + "hp": "5", + "motives_and_tactics": "Develop new spells, seek power, shatter formations", + "name": "War Wizard", + "range": "Far", + "stress": "6", + "thresholds": "11/23", + "tier": "2", + "type": "Ranged" + }, + { + "atk": "+3", + "attack": "Wing Slash", + "damage": "3d20 phy", + "description": "A winged insect the size of a large house with iridescent scales and wings that move too fast to track.", + "difficulty": "17", + "feature": [ + { + "name": "Relentless (4) - Passive", + "text": "The Flickerfly can be spotlighted up to four times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Never Misses - Passive", + "text": "When the Flickerfly makes an attack, the target's Evasion is halved against the attack." + }, + { + "name": "Deadly Flight - Passive", + "text": "While flying, the Flickerfly can move up to Far range instead of Close range before taking an action." + }, + { + "name": "Whirlwind - Action", + "text": "**Spend a Fear** to whirl, making an attack against all targets within Very Close range. Targets the Flickerfly succeeds against take **3d8** direct physical damage." + }, + { + "name": "Mind Dance - Action", + "text": "**Mark a Stress** to create a magically dazzling display that grapples the minds of nearby foes. All targets within Close range must make an Instinct Reaction Roll. For each target who failed, you gain a Fear and the Flickerfly learns one of the target's fears." + }, + { + "name": "Hallucinatory Breath - Reaction: Countdown (Loop 1d6)", + "text": "When the Flickerfly takes damage for the first time, activate the countdown. When it triggers, the Flickerfly breathes hallucinatory gas on all targets in front of them up to Far range. Targets must make an Instinct Reaction Roll or be tormented by fearful hallucinations. Targets whose fears are known to the Flickerfly have disadvantage on this roll. Targets who fail lose 2 Hope and take **3d8+3** direct magic damage." + }, + { + "name": "Uncanny Reflexes - Reaction", + "text": "When the Flickerfly takes damage from an attack within Close range, you can **mark a Stress** to take half damage." + } + ], + "hp": "12", + "motives_and_tactics": "Collect shiny things, hunt, nest, swoop", + "name": "Adult Flickerfly", + "range": "Very Close", + "stress": "6", + "thresholds": "20/35", + "tier": "3", + "type": "Solo" + }, + { + "atk": "+2", + "attack": "Hungry Maw", + "damage": "3d6+5 mag", + "description": "A regal cloaked monstrosity with circular horns adorned with treasure.", + "difficulty": "17", + "experience": "Manipulation +3", + "feature": [ + { + "name": "Money Talks - Passive", + "text": "Attacks against the Demon are made with disadvantage unless the attacker spends a handful of gold. This Demon starts with a number of handfuls equal to the number of PCs. When a target marks HP from the Demon's standard attack, they can spend a handful of gold instead of marking HP (1 handful per HP). Add a handful of gold to the Demon for each handful of gold spent by PCs on this feature." + }, + { + "name": "Numbers Must Go Up - Passive", + "text": "Add a bonus to the Demon's attack rolls equal to the number of handfuls of gold they have." + }, + { + "name": "Money Is Time - Action", + "text": "**Spend 3 handfuls of gold (or a Fear)** to spotlight **1d4+1** allies." + } + ], + "hp": "6", + "motives_and_tactics": "Consume, fuel greed, sow dissent", + "name": "Demon of Avarice", + "range": "Melee", + "stress": "5", + "thresholds": "15/29", + "tier": "3", + "type": "Support" + }, + { + "atk": "+3", + "attack": "Miasma Bolt", + "damage": "3d6+1 mag", + "description": "A cloaked demon-creature with long limbs, seeping shadows.", + "difficulty": "17", + "experience": "Manipulation +3", + "feature": [ + { + "name": "Depths of Despair - Passive", + "text": "The Demon deals double damage to PCs with 0 Hope." + }, + { + "name": "Your Struggle Is Pointless - Action", + "text": "**Spend a Fear** to weigh down the spirits of all PCs within Far range. All targets affected replace their Hope Die with a **d8** until they roll a success with Hope or their next rest." + }, + { + "name": "Your Friends Will Fail You - Reaction", + "text": "When a PC fails with Fear, you can **mark a Stress** to cause all other PCs within Close range to lose a Hope." + }, + { + "name": "Momentum - Reaction", + "text": "When the Demon makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Make fear contagious, stick to the shadows, undermine resolve", + "name": "Demon of Despair", + "range": "Far", + "stress": "5", + "thresholds": "18/35", + "tier": "3", + "type": "Skulk" + }, + { + "atk": "+4", + "attack": "Perfect Spear", + "damage": "3d10 phy", + "description": "A perfectly beautiful and infinitely cruel demon with a gleaming spear and elegant robes.", + "difficulty": "18", + "experience": "Manipulation +2", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Demon makes a successful attack, all PCs within Far range must lose a Hope and you gain a Fear." + }, + { + "name": "Double or Nothing - Passive", + "text": "When a PC within Far range fails a roll, they can choose to reroll their Fear Die and take the new result. If they still fail, they mark 2 Stress and the Demon clears a Stress." + }, + { + "name": "Unparalleled Skill - Action", + "text": "**Mark a Stress** to deal the Demon's standard attack damage to a target within Close range." + }, + { + "name": "The Root of Villainy - Action", + "text": "**Spend a Fear** to spotlight two other Demons within Far range." + }, + { + "name": "You Pale in Comparison - Reaction", + "text": "When a PC fails a roll within Close range of the Demon, they must mark a Stress." + } + ], + "hp": "7", + "motives_and_tactics": "Condescend, declare premature victory, prove superiority", + "name": "Demon of Hubris", + "range": "Very Close", + "stress": "5", + "thresholds": "18/36", + "tier": "3", + "type": "Leader" + }, + { + "atk": "+4", + "attack": "Psychic Assault", + "damage": "3d8+3 mag", + "description": "A fickle creature of spindly limbs and insatiable desires.", + "difficulty": "17", + "experience": "Manipulation +3", + "feature": [ + { + "name": "Unprotected Mind - Passive", + "text": "The Demon's standard attack deals direct damage." + }, + { + "name": "My Turn - Reaction", + "text": "When the Demon marks HP from an attack, **spend a number of Fear equal to the HP marked by the Demon** to cause the attacker to mark the same number of HP." + }, + { + "name": "Rivalry - Reaction", + "text": "When a creature within Close range takes damage from a different adversary, you can **mark a Stress** to add a **d4** to the damage roll." + }, + { + "name": "What's Yours Is Mine - Reaction", + "text": "When a PC takes Severe damage within Very Close range of the Demon, you can **spend a Fear** to cause the target to make a Finesse Reaction Roll. On a failure, the Demon seizes one item or consumable of their choice from the target's inventory." + } + ], + "hp": "6", + "motives_and_tactics": "Join in on others' success, take what belongs to others, hold grudges", + "name": "Demon of Jealousy", + "range": "Far", + "stress": "6", + "thresholds": "17/30", + "tier": "3", + "type": "Ranged" + }, + { + "atk": "+3", + "attack": "Fists", + "damage": "3d8+1 mag", + "description": "A hulking demon with boulder-sized fists, driven by endless rage.", + "difficulty": "17", + "experience": "Intimidation +2", + "feature": [ + { + "name": "Anger Unrelenting - Passive", + "text": "The Demon's attacks deal direct damage." + }, + { + "name": "Battle Lust - Action", + "text": "**Spend a Fear** to boil the blood of all PCs within Far range. They use a d20 as their Fear Die until the end of the scene." + }, + { + "name": "Retaliation - Reaction", + "text": "When the Demon takes damage from an attack within Close range, you can **mark a Stress** to make a standard attack against the attacker." + }, + { + "name": "Blood and Souls - Reaction: Countdown (Loop 6)", + "text": "Activate the first time an attack is made within sight of the Demon. It ticks down when a PC takes a violent action. When it triggers, summon **1d4** Minor Demons, who appear at Close range." + } + ], + "hp": "7", + "motives_and_tactics": "Fuel anger, impress rivals, wreak havoc", + "name": "Demon of Wrath", + "range": "Very Close", + "stress": "5", + "thresholds": "22/40", + "tier": "3", + "type": "Bruiser" + }, + { + "atk": "+2", + "attack": "Claws and Teeth", + "damage": "2d6+7 phy", + "description": "A wide-winged pet endlessly loyal to their vampire owner.", + "difficulty": "14", + "experience": "Bloodthirsty +3", + "feature": [ + { + "name": "Flying - Passive", + "text": "While flying, the Bat gains a +3 bonus to their Difficulty." + }, + { + "name": "Screech - Action", + "text": "**Mark a Stress** to send a high-pitch screech out toward all targets in front of the Bat within Far range. Those targets must mark **1d4** Stress." + }, + { + "name": "Guardian - Reaction", + "text": "When an allied Vampire marks HP, you can **mark a Stress** to fly into Melee range of the attacker and make an attack with advantage against them. On a success, deal **2d6+2** physical damage." + } + ], + "hp": "5", + "motives_and_tactics": "Dive-bomb, hide, protect leader", + "name": "Dire Bat", + "range": "Melee", + "stress": "3", + "thresholds": "16/30", + "tier": "3", + "type": "Skulk" + }, + { + "atk": "+4", + "attack": "Deadfall Shortbow", + "damage": "3d10+1 phy", + "description": "A nature spirit in the form of a humanoid tree.", + "difficulty": "16", + "experience": "Forest Knowledge +4", + "feature": [ + { + "name": "Bramble Patch - Action", + "text": "**Mark a Stress** to target a point within Far range. Create a patch of thorns that covers an area within Close range of that point. All targets within that area take **2d6+2** physical damage when they act. A target must succeed on a Finesse Roll or deal more than 20 damage to the Dryad with an attack to leave the area." + }, + { + "name": "Grow Saplings - Action", + "text": "**Spend a Fear** to grow three Treant Sapling Minions, who appear at Close range and immediately take the spotlight." + }, + { + "name": "We Are All One - Reaction", + "text": "When an ally dies within Close range, you can **spend a Fear** to clear 2 HP and 2 Stress as the fallen ally's life force is returned to the forest." + } + ], + "hp": "8", + "motives_and_tactics": "Command, cultivate, drive out, preserve the forest", + "name": "Dryad", + "range": "Far", + "stress": "5", + "thresholds": "24/38", + "tier": "3", + "type": "Leader" + }, + { + "atk": "+0", + "attack": "Bursts of Fire", + "damage": "5 mag", + "description": "A blazing mote of elemental fire.", + "difficulty": "15", + "feature": [ + { + "name": "Minion (9) - Passive", + "text": "The Elemental is defeated when they take any damage. For every 9 damage a PC deals to the Elemental, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Elemental Sparks within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Blast, consume, gain mass", + "name": "Elemental Spark", + "range": "Close", + "stress": "1", + "thresholds": "None", + "tier": "3", + "type": "Minion" + }, + { + "atk": "+7", + "attack": "Boulder Fist", + "damage": "3d10+1 phy", + "description": "A living landslide of boulders and dust, as large as a house.", + "difficulty": "17", + "feature": [ + { + "name": "Slow - Passive", + "text": "When you spotlight the Elemental and they don't have a token on their stat block, they can't act yet. Place a token on their stat block and describe what they're preparing to do. When you spotlight the Elemental and they have a token on their stat block, clear the token and they can act." + }, + { + "name": "Crushing Blows - Passive", + "text": "When the Elemental makes a successful attack, the target must mark an Armor Slot without receiving its benefits (they can still use armor to reduce the damage). If they can't mark an Armor Slot, they must mark an additional HP." + }, + { + "name": "Immovable Object - Passive", + "text": "An attack that would move the Elemental moves them two fewer ranges (for example, Far becomes Very Close). When the Elemental takes physical damage, reduce it by 7." + }, + { + "name": "Rockslide - Action", + "text": "**Mark a Stress** to create a rockslide that buries the land in front of Elemental within Close range with rockfall. All targets in this area must make an Agility Reaction Roll (19). Targets who fail take **2d12+5** physical damage and become _Vulnerable_ until their next roll with Hope. Targets who succeed take half damage." + }, + { + "name": "Momentum - Reaction", + "text": "When the Elemental makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "10", + "motives_and_tactics": "Avalanche, knock over, pummel", + "name": "Greater Earth Elemental", + "range": "Very Close", + "stress": "4", + "thresholds": "22/40", + "tier": "3", + "type": "Bruiser" + }, + { + "atk": "+3", + "attack": "Crashing Wave", + "damage": "3d4+1 mag", + "description": "A huge living wave that crashes down upon enemies.", + "difficulty": "17", + "feature": [ + { + "name": "Water Jet - Action", + "text": "**Mark a Stress** to attack a target within Very Close range. On a success, deal **2d4+7** physical damage and the target's next action has disadvantage. On a failure, the target must mark a Stress." + }, + { + "name": "Drowning Embrace - Action", + "text": "**Spend a Fear** to make an attack against all targets within Very Close range. Targets the Elemental succeeds against become _Restrained_ and _Vulnerable_ as they begin drowning. A target can break free, ending both conditions, with a successful Strength or Instinct Roll." + }, + { + "name": "High Tide - Reaction", + "text": "When the Elemental makes a successful standard attack, you can **mark a Stress** to knock the target back to Close range." + } + ], + "hp": "5", + "motives_and_tactics": "Deluge, disperse, drown", + "name": "Greater Water Elemental", + "range": "Very Close", + "stress": "5", + "thresholds": "17/34", + "tier": "3", + "type": "Support" + }, + { + "atk": "+3", + "attack": "Ooze Appendage", + "damage": "3d8+1 mag", + "description": "A translucent green mound of acid taller than most humans.", + "difficulty": "15", + "experience": "Blend In +3", + "feature": [ + { + "name": "Slow - Passive", + "text": "When you spotlight the Ooze and they don't have a token on their stat block, they can't act yet. Place a token on their stat block and describe what they're preparing to do. When you spotlight the Ooze and they have a token on their stat block, clear the token and they can act." + }, + { + "name": "Acidic Form - Passive", + "text": "When the Ooze makes a successful attack, the target must mark an Armor Slot without receiving its benefits (they can still use armor to reduce the damage). If they can't mark an Armor Slot, they must mark an additional HP." + }, + { + "name": "Envelop - Action", + "text": "Make an attack against a target within Melee range. On a success, the Ooze _Envelops_ them and the target must mark 2 Stress. While _Enveloped_, the target must mark an additional Stress every time they make an action roll. When the Ooze takes Severe damage, all _Enveloped_ targets are freed and the condition is cleared." + }, + { + "name": "Split - Reaction", + "text": "When the Ooze has 4 or more HP marked, you can **spend a Fear** to split them into two Green Oozes (with no marked HP or Stress). Immediately spotlight both of them." + } + ], + "hp": "7", + "motives_and_tactics": "Camouflage, creep up, envelop, multiply", + "name": "Huge Green Ooze", + "range": "Melee", + "stress": "4", + "thresholds": "15/30", + "tier": "3", + "type": "Skulk" + }, + { + "atk": "+3", + "attack": "Bite", + "damage": "2d12+2 phy", + "description": "A quadrupedal scaled beast with multiple long-necked heads, each filled with menacing fangs.", + "difficulty": "18", + "feature": [ + { + "name": "Many-Headed Menace - Passive", + "text": "The Hydra begins with three heads and can have up to five. When the Hydra takes Major or greater damage, they lose a head." + }, + { + "name": "Relentless (X) - Passive", + "text": "The Hydra can be spotlighted X times per GM turn, where X is the Hydra's number of heads. Spend Fear as usual to spotlight them." + }, + { + "name": "Regeneration - Action", + "text": "If the Hydra has any marked HP, **spend a Fear** to clear a HP and grow two heads." + }, + { + "name": "Terrifying Chorus - Action", + "text": "All PCs within Far range lose 2 Hope." + }, + { + "name": "Magical Weakness - Reaction", + "text": "When the Hydra takes magic damage, they become _Dazed_ until the next roll with Fear. While _Dazed_, they can't use their Regeneration action but are immune to magic damage." + } + ], + "hp": "10", + "motives_and_tactics": "Devour, regenerate, terrify", + "name": "Hydra", + "range": "Close", + "stress": "5", + "thresholds": "19/35", + "tier": "3", + "type": "Solo" + }, + { + "atk": "+0", + "attack": "Warhammer", + "damage": "3d6+3 phy", + "description": "The sovereign ruler of a nation, wreathed in the privilege of tradition and wielding unmatched power in their domain.", + "difficulty": "16", + "experience": "History +3, Nobility +3", + "feature": [ + { + "name": "Execute Them! - Action", + "text": "**Spend a Fear** per PC in the party to have the group condemned for crimes real or imagined. A PC who succeeds on a Presence Roll can demand trial by combat or another special form of trial." + }, + { + "name": "Crownsguard - Action", + "text": "Once per scene, **mark a Stress** to summon six Tier 3 Minions, who appear at Close range to enforce the Monarch's will." + }, + { + "name": "Casus Belli - Reaction: Long-Term Countdown (8)", + "text": "**Spend a Fear** to activate after the Monarch's desire for war is first revealed. When it triggers, the Monarch has a reason to rally the nation to war and the support to act on that reason. You gain **1d4** Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Control vassals, destroy rivals, forge a legacy", + "name": "Monarch", + "range": "Melee", + "stress": "5", + "thresholds": "16/32", + "tier": "3", + "type": "Social" + }, + { + "atk": "+3", + "attack": "Bramble Sword", + "damage": "3d8+3 phy", + "description": "A knight with huge, majestic antlers wearing armor made of dangerous thorns.", + "difficulty": "17", + "experience": "Forest Knowledge +3", + "feature": [ + { + "name": "From Above - Passive", + "text": "When the Knight succeeds on a standard attack from above a target, they deal **3d12+3** physical damage instead of their standard damage." + }, + { + "name": "Blade of the Forest - Action", + "text": "**Spend a Fear** to make an attack against all targets within Very Close range. Targets the Knight succeeds against take physical damage equal to **3d4** + the target's Major threshold." + }, + { + "name": "Thorny Armor - Reaction", + "text": "When the Knight takes damage from an attack within Melee range, you can **mark a Stress** to deal **1d10+5** physical damage to the attacker." + } + ], + "hp": "7", + "motives_and_tactics": "Isolate, maneuver, protect the forest, weed the unwelcome", + "name": "Stag Knight", + "range": "Melee", + "stress": "5", + "thresholds": "19/36", + "tier": "3", + "type": "Standard" + }, + { + "atk": "+2", + "attack": "Branch", + "damage": "3d8+2 phy", + "description": "A sturdy animate old-growth tree.", + "difficulty": "17", + "experience": "Forest Knowledge +3", + "feature": [ + { + "name": "Just a Tree - Passive", + "text": "Before they make their first attack in a fight or after they become _Hidden_, the Treant is indistinguishable from other trees until they next act or a PC succeeds on an Instinct Roll to identify them." + }, + { + "name": "Seed Barrage - Action", + "text": "**Mark a Stress** and make an attack against up to three targets within Close range, pummeling them with giant acorns. Targets the Treant succeeds against take **2d10+5** physical damage." + }, + { + "name": "Take Root - Action", + "text": "**Mark a Stress** to _Root_ the Treant in place. The Treant is _Restrained_ while _Rooted_, and can end this effect instead of moving while they are spotlighted. While Rooted, the Treant has resistance to physical damage." + } + ], + "hp": "7", + "motives_and_tactics": "Hide in plain sight, preserve the forest, root down, swing branches", + "name": "Oak Treant", + "range": "Very Close", + "stress": "4", + "thresholds": "22/40", + "tier": "3", + "type": "Bruiser" + }, + { + "atk": "+5", + "attack": "Rapier", + "damage": "2d20+4 phy", + "description": "A captivating undead dressed in aristocratic finery.", + "difficulty": "17", + "experience": "Aristocrat +3", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Vampire makes a successful attack, all PCs within Far range lose a Hope and you gain a Fear." + }, + { + "name": "Look into My Eyes - Passive", + "text": "A creature who moves into Melee range of the Vampire must make an Instinct Reaction Roll. On a failure, you gain **1d4** Fear." + }, + { + "name": "Feed on Followers - Action", + "text": "When the Vampire is within Melee range of an ally, they can cause the ally to mark a HP. The Vampire then clears a HP." + }, + { + "name": "The Hunt Is On - Action", + "text": "**Spend 2 Fear** to summon **1d4** Vampires, who appear at Far range and immediately take the spotlight." + }, + { + "name": "Lifesuck - Reaction", + "text": "When the Vampire is spotlighted, roll a **d8**. On a result of 6 or higher, all targets within Very Close range must mark a HP." + } + ], + "hp": "6", + "motives_and_tactics": "Create thralls, charm, command, fly, intimidate", + "name": "Head Vampire", + "range": "Melee", + "stress": "6", + "thresholds": "22/42", + "tier": "3", + "type": "Leader" + }, + { + "atk": "+0", + "attack": "Branches", + "damage": "8 phy", + "description": "A small, sentient tree sapling.", + "difficulty": "14", + "feature": [ + { + "name": "Minion (6) - Passive", + "text": "The Sapling is defeated when they take any damage. For every 6 damage a PC deals to the Sapling, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Treant Saplings within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 8 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Blend in, preserve the forest, pummel, surround", + "name": "Treant Sapling", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "3", + "type": "Minion" + }, + { + "atk": "+3", + "attack": "Rapier", + "damage": "3d8 phy", + "description": "An intelligent undead with blood-stained lips and a predator's smile.", + "difficulty": "16", + "experience": "Nocturnal Hunter +3", + "feature": [ + { + "name": "Draining Bite - Action", + "text": "Make an attack against a target within Melee range. On a success, deal **5d4** physical damage. A target who marks HP from this attack loses a Hope and must mark a Stress. The Vampire then clears a HP." + }, + { + "name": "Mistform - Reaction", + "text": "When the Vampire takes physical damage, you can **spend a Fear** to take half damage." + } + ], + "hp": "5", + "motives_and_tactics": "Bite, charm, deceive, feed, intimidate", + "name": "Vampire", + "range": "Melee", + "stress": "4", + "thresholds": "18/35", + "tier": "3", + "type": "Standard" + }, + { + "atk": "+2", + "attack": "Body Bash", + "damage": "3d6+2 phy", + "description": "A boxy, dust-covered construct with thick metallic swinging doors on their torso.", + "difficulty": "16", + "feature": [ + { + "name": "Blocking Shield - Passive", + "text": "Creatures within Melee range of the Gaoler have disadvantage on attack rolls against them. Creatures trapped inside the Gaoler are immune to this feature." + }, + { + "name": "Lock Up - Action", + "text": "**Mark a Stress** to make an attack against a target within Very Close range. On a success, the target is _Restrained_ within the Gaoler until freed with a successful Strength Roll (18). While _Restrained_, the target can only attack the Gaoler." + } + ], + "hp": "5", + "motives_and_tactics": "Carry away, entrap, protect, pummel", + "name": "Vault Guardian Gaoler", + "range": "Very Close", + "stress": "3", + "thresholds": "19/33", + "tier": "3", + "type": "Support" + }, + { + "atk": "+3", + "attack": "Charged Mace", + "damage": "2d12+1 phy", + "description": "A dust-covered golden construct with boxy limbs and a huge mace for a hand.", + "difficulty": "17", + "feature": [ + { + "name": "Kinetic Slam - Passive", + "text": "Targets who take damage from the Sentinel's standard attack are knocked back to Very Close range." + }, + { + "name": "Box In - Action", + "text": "**Mark a Stress** to choose a target within Very Close range to focus on. That target has disadvantage on attack rolls when they're within Very Close range of the Sentinel. The Sentinel can only focus on one target at a time." + }, + { + "name": "Mana Bolt - Action", + "text": "**Spend a Fear** to lob explosive magic at a point within Far range. All targets within Very Close range of that point must make an Agility Reaction Roll. Targets who fail take **2d8+20** magic damage and are knocked back to Close range. Targets who succeed take half damage and aren't knocked back." + }, + { + "name": "Momentum - Reaction", + "text": "When the Sentinel makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Destroy at any cost, expunge, protect", + "name": "Vault Guardian Sentinel", + "range": "Very Close", + "stress": "3", + "thresholds": "21/40", + "tier": "3", + "type": "Bruiser" + }, + { + "atk": "+3", + "attack": "Magitech Cannon", + "damage": "3d10+3 mag", + "description": "A massive living turret with reinforced armor and twelve pistondriven mechanical legs.", + "difficulty": "16", + "feature": [ + { + "name": "Slow Firing - Passive", + "text": "When you spotlight the Turret and they don't have a token on their stat block, they can't make a standard attack. Place a token on their stat block and describe what they're preparing to do. When you spotlight the Turret and they have a token on their stat block, clear the token and they can attack." + }, + { + "name": "Mark Target - Action", + "text": "**Spend a Fear** to _Mark_ a target within Far range until the Turret is destroyed or the _Marked_ target becomes _Hidden_. While the target is _Marked_, their Evasion is halved." + }, + { + "name": "Concentrate Fire - Reaction", + "text": "When another adversary deals damage to a target within Far range of the Turret, you can **mark a Stress** to add the Turret's standard attack damage to the damage roll." + }, + { + "name": "Detonation - Reaction", + "text": "When the Turret is destroyed, they explode. All targets within Close range must make an Agility Reaction Roll. Targets who fail take **3d20** physical damage. Targets who succeed take half damage." + } + ], + "hp": "5", + "motives_and_tactics": "Concentrate fire, lock down, mark, protect", + "name": "Vault Guardian Turret", + "range": "Far", + "stress": "4", + "thresholds": "20/32", + "tier": "3", + "type": "Ranged" + }, + { + "atk": "+7", + "attack": "Bite and Claws", + "damage": "4d10 phy", + "description": "A glacier-blue dragon with four powerful limbs and frost-tinged wings.", + "difficulty": "18", + "experience": "Protect What Is Mine +3", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Dragon can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Rend and Crush - Passive", + "text": "If a target damaged by the Dragon doesn't mark an Armor Slot to reduce the damage, they must mark a Stress." + }, + { + "name": "No Hope - Passive", + "text": "When a PC rolls with Fear while within Far range of the Dragon, they lose a Hope." + }, + { + "name": "Blizzard Breath - Action", + "text": "**Spend 2 Fear** to release an icy whorl in front of the Dragon within Close range. All targets in this area must make an Agility Reaction Roll. Targets who fail take **4d6+5** magic damage and are _Restrained_ by ice until they break free with a successful Strength Roll. Targets who succeed must mark 2 Stress or take half damage." + }, + { + "name": "Avalanche - Action", + "text": "**Spend a Fear** to have the Dragon unleash a huge downfall of snow and ice, covering all other creatures within Far range. All targets within this area must succeed on an Instinct Reaction Roll or be buried in snow and rocks, becoming _Vulnerable_ until they dig themselves out from the debris. For each PC that fails the reaction roll, you gain a Fear." + }, + { + "name": "Frozen Scales - Reaction", + "text": "When a creature makes a successful attack against the Dragon from within Very Close range, they must mark a Stress and become _Chilled_ until their next rest or they clear a Stress. While they are _Chilled_, they have disadvantage on attack rolls." + }, + { + "name": "Momentum - Reaction", + "text": "When the Dragon makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "10", + "motives_and_tactics": "Avalanche, defend lair, fly, freeze, defend what is mine, maul", + "name": "Young Ice Dragon", + "range": "Close", + "stress": "6", + "thresholds": "21/41", + "tier": "3", + "type": "Solo" + }, + { + "atk": "+6", + "attack": "Necrotic Blast", + "damage": "4d12+8 mag", + "description": "A decaying mage adorned in dark, tattered robes.", + "difficulty": "21", + "experience": "Forbidden Knowledge +3, Wisdom of Centuries +3", + "feature": [ + { + "name": "Dance of Death - Action", + "text": "**Mark a Stress** to spotlight **1d4** allies. Attacks they make while spotlighted in this way deal half damage, or full damage if you **spend a Fear**." + }, + { + "name": "Beam of Decay - Action", + "text": "**Mark 2 Stress** to cause all targets within Far range to make a Strength Reaction Roll. Targets who fail take **2d20+12** magic damage and you gain a Fear. Targets who succeed take half damage. A target who marks 2 or more HP must also mark **2 Stress** and becomes _Vulnerable_ until they roll with Hope." + }, + { + "name": "Open the Gates of Death - Action", + "text": "**Spend a Fear** to summon a Zombie Legion, which appears at Close range and immediately takes the spotlight." + }, + { + "name": "Not Today, My Dears - Reaction", + "text": "When the Necromancer has marked 7 or more of their HP, you can **spend a Fear** to have them teleport away to a safe location to recover. A PC who succeeds on an Instinct Roll can trace the teleportation magic to their destination." + }, + { + "name": "Your Life Is Mine - Reaction: Countdown (Loop 2d6)", + "text": "When the Necromancer has marked 6 or more of their HP, activate the countdown. When it triggers, deal **2d10+6** direct magic damage to a target within Close range. The Necromancer then **clears a number of Stress or HP** equal to the number of HP marked by the target from this attack." + } + ], + "hp": "9", + "motives_and_tactics": "Corrupt, decay, flee to fight another day, resurrect", + "name": "Arch-Necromancer", + "range": "Far", + "stress": "8", + "thresholds": "33/66", + "tier": "4", + "type": "Leader" + }, + { + "atk": "+2", + "attack": "Cursed Axe", + "damage": "12 phy", + "description": "A cursed soul bound to the Fallen's will.", + "difficulty": "18", + "feature": [ + { + "name": "Minion (12) - Passive", + "text": "The Shock Troop is defeated when they take any damage. For every 12 damage a PC deals to the Shock Troop, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Aura of Doom - Passive", + "text": "When a PC marks HP from an attack by the Shock Troop, they lose a Hope." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Fallen Shock Troops within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 12 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Crush, dominate, earn relief, punish", + "name": "Fallen Shock Troop", + "range": "Very Close", + "stress": "1", + "thresholds": "None", + "tier": "4", + "type": "Minion" + }, + { + "atk": "+4", + "attack": "Corrupted Staff", + "damage": "4d6+10 mag", + "description": "A powerful mage bound by the bargains they made in life.", + "difficulty": "19", + "experience": "Ancient Knowledge +2", + "feature": [ + { + "name": "Conflagration - Action", + "text": "**Spend a Fear** to unleash an all-consuming firestorm and make an attack against all targets within Close range. Targets the Sorcerer succeeds against take **2d10+6** direct magic damage." + }, + { + "name": "Nightmare Tableau - Action", + "text": "**Mark a Stress** to trap a target within Far range in a powerful illusion of their worst fears. While trapped, the target is _Restrained_ and _Vulnerable_ until they break free, ending both conditions, with a successful Instinct Roll." + }, + { + "name": "Slippery - Reaction", + "text": "When the Sorcerer takes damage from an attack, they can teleport up to Far range." + }, + { + "name": "Shackles of Guilt - Reaction: Countdown (Loop 2d6)", + "text": "When the Sorcerer is in the spotlight for the first time, activate the countdown. When it triggers, all targets within Far range become _Vulnerable_ and must mark a Stress as they relive their greatest regrets. A target can break free from their regret with a successful Presence or Strength Roll. When a PC fails to break free, they lose a Hope." + } + ], + "hp": "6", + "motives_and_tactics": "Acquire, dishearten, dominate, torment", + "name": "Fallen Sorcerer", + "range": "Far", + "stress": "5", + "thresholds": "26/42", + "tier": "4", + "type": "Support" + }, + { + "atk": "+7", + "attack": "Barbed Whip", + "damage": "4d8+7 phy", + "description": "A Fallen God, wreathed in rage and resentment, bearing millennia of experience in breaking heroes' spirits.", + "difficulty": "20", + "experience": "Conquest +3, History +2, Intimidation +3", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Realm-Breaker can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Firespite Plate Armor - Passive", + "text": "When the Realm-Breaker takes damage, reduce it by **2d10**." + }, + { + "name": "Tormenting Lash - Action", + "text": "**Mark a Stress** to make a standard attack against all targets within Very Close range. When a target uses armor to reduce damage from this attack, they must mark 2 Armor Slots." + }, + { + "name": "All-Consuming Rage - Reaction: Countdown (Decreasing 8)", + "text": "When the Realm-Breaker is in the spotlight for the first time, activate the countdown. When it triggers, create a torrent of incarnate rage that rends flesh from bone. All targets within Far range must make a Presence Reaction Roll. Targets who fail take **2d6+10** direct magic damage. Targets who succeed take half damage. For each HP marked from this damage, summon a Fallen Shock Troop within Very Close range of the target who marked that HP. If the countdown ever decreases its maximum value to 0, the Realm-Breaker marks their remaining HP and all targets within Far range must mark all remaining HP and make a death move." + }, + { + "name": "Doombringer - Reaction", + "text": "When a target marks HP from an attack by the Realm-Breaker, all PCs within Far range of the target must lose a Hope." + }, + { + "name": "I Have Never Known Defeat (Phase Change) - Reaction", + "text": "When the Realm-Breaker marks their last HP, replace them with the Undefeated Champion and immediately spotlight them." + } + ], + "hp": "8", + "motives_and_tactics": "Corrupt, dominate, punish, break the weak", + "name": "Fallen Warlord: Realm-Breaker", + "range": "Close", + "stress": "5", + "thresholds": "36/66", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+8", + "attack": "Heart-Shattering Sword", + "damage": "4d12+13 phy", + "description": "That which only the most feared have a chance to fear.", + "difficulty": "18", + "experience": "Conquest +3, History +2, Intimidation +3", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Undefeated Champion can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Faltering Armor - Passive", + "text": "When the Undefeated Champion takes damage, reduce it by **1d10**." + }, + { + "name": "Shattering Strike - Action", + "text": "**Mark a Stress** to make a standard attack against all targets within Very Close range. PCs the Champion succeeds against lose a number of Hope equal to the HP they marked from this attack." + }, + { + "name": "Endless Legions - Action", + "text": "**Spend a Fear** to summon a number of Fallen Shock Troops equal to twice the number of PCs. The Shock Troops appear at Far range." + }, + { + "name": "Circle of Defilement - Reaction: Countdown (1d8)", + "text": "When the Undefeated Champion is in the spotlight for the first time, activate the countdown. When it triggers, activate a magical circle covering an area within Far range of the Champion. A target within that area is _Vulnerable_ until they leave the circle. The circle can be removed by dealing Severe damage to the Undefeated Champion." + }, + { + "name": "Momentum - Reaction", + "text": "When the Undefeated Champion makes a successful attack against a PC, you gain a Fear." + }, + { + "name": "Doombringer - Reaction", + "text": "When a target marks HP from an attack by the Undefeated Champion, all PCs within Far range of the target lose a Hope." + } + ], + "hp": "11", + "motives_and_tactics": "Dispatch merciless death, punish the defiant, secure victory at any cost", + "name": "Fallen Warlord: Undefeated Champion", + "range": "Very Close", + "stress": "5", + "thresholds": "35/58", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+4", + "attack": "Sanctified Longbow", + "damage": "4d8+8 phy", + "description": "Spirit soldiers with sanctified bows.", + "difficulty": "19", + "feature": [ + { + "name": "Punish the Guilty - Passive", + "text": "The Archer deals double damage to targets marked _Guilty_ by a High Seraph." + }, + { + "name": "Divine Volley - Action", + "text": "**Mark a Stress** to make a standard attack against up to three targets." + } + ], + "hp": "3", + "motives_and_tactics": "Focus fire, obey, reposition, volley", + "name": "Hallowed Archer", + "range": "Far", + "stress": "2", + "thresholds": "25/45", + "tier": "4", + "type": "Ranged" + }, + { + "atk": "+2", + "attack": "Sword and Shield", + "damage": "10 phy", + "description": "Souls of the faithful, lifted up with divine weaponry.", + "difficulty": "18", + "feature": [ + { + "name": "Minion (13) - Passive", + "text": "The Soldier is defeated when they take any damage. For every 13 damage a PC deals to the Soldier, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Divine Flight - Passive", + "text": "While the Soldier is flying, **spend a Fear** to move up to Far range instead of Close range before taking an action." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Hallowed Soldiers within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 10 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Obey, outmaneuver, punish, swarm", + "name": "Hallowed Soldier", + "range": "Melee", + "stress": "2", + "thresholds": "None", + "tier": "4", + "type": "Minion" + }, + { + "atk": "+8", + "attack": "Holy Sword", + "damage": "4d10+10 phy", + "description": "A divine champion, head of a hallowed host of warriors who enforce their god's will.", + "difficulty": "20", + "experience": "Divine Knowledge +3", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Seraph can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Divine Flight - Passive", + "text": "While the Seraph is flying, **spend a Fear** to move up to Far range instead of Close range before taking an action." + }, + { + "name": "Judgment - Action", + "text": "**Spend a Fear** to make a target _Guilty_ in the eyes of the Seraph's god until the Seraph is defeated. While _Guilty_, the target doesn't gain Hope on a result with Hope. When the Seraph succeeds on a standard attack against a _Guilty_ target, they deal Severe damage instead of their standard damage. The Seraph can only mark one target at a time." + }, + { + "name": "God Rays - Action", + "text": "**Mark a Stress** to reflect a sliver of divinity as a searing beam of light that hits up to twenty targets within Very Far range. Targets must make a Presence Reaction Roll, with disadvantage if they are marked _Guilty_. Targets who fail take **4d6+12** magic damage. Targets who succeed take half damage." + }, + { + "name": "We Are One - Action", + "text": "Once per scene, **spend a Fear** to spotlight all other adversaries within Far range. Attacks they make while spotlighted in this way deal half damage." + } + ], + "hp": "7", + "motives_and_tactics": "Enforce dogma, fly, pronounce judgment, smite", + "name": "High Seraph", + "range": "Very Close", + "stress": "5", + "thresholds": "37/70", + "tier": "4", + "type": "Leader" + }, + { + "atk": "+7", + "attack": "Tentacles", + "damage": "4d12+10 phy", + "description": "A legendary beast of the sea, bigger than the largest galleon, with sucker-laden tentacles and a terrifying maw.", + "difficulty": "20", + "experience": "Swimming +3", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Kraken can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Many Tentacles - Passive", + "text": "While the Kraken has 7 or fewer marked HP, they can make their standard attack against two targets within range." + }, + { + "name": "Grapple and Drown - Action", + "text": "Make an attack roll against a target within Close range. On a success, **mark a Stress** to grab them with a tentacle and drag them beneath the water. The target is _Restrained_ and _Vulnerable_ until they break free with a successful Strength Roll or the Kraken takes Major or greater damage. While _Restrained_ and _Vulnerable_ in this way, a target must mark a Stress when they make an action roll." + }, + { + "name": "Boiling Blast - Action", + "text": "**Spend a Fear** to spew a line of boiling water at any number of targets in a line up to Far range. All targets must succeed on an Agility Reaction Roll or take **4d6+9** physical damage. If a target marks an Armor Slot to reduce the damage, they must also mark a Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Kraken makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "11", + "motives_and_tactics": "Consume, crush, drown, grapple", + "name": "Kraken", + "range": "Close", + "stress": "8", + "thresholds": "35/70", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+8", + "attack": "Psychic Attack", + "damage": "4d8+9 mag", + "description": "A towering immortal and incarnation of fate, cursed to only see bad outcomes.", + "difficulty": "20", + "experience": "Boundless Knowledge +4", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Oracle makes a successful attack, all PCs within Far range lose a Hope and you gain a Fear." + }, + { + "name": "Walls Closing In - Passive", + "text": "When a creature rolls a failure while within Very Far range of the Oracle, they must mark a Stress." + }, + { + "name": "Pronounce Fate - Action", + "text": "**Spend a Fear** to present a target within Far range with a vision of their personal nightmare. The target must make a Knowledge Reaction Roll. On a failure, they lose all Hope and take **2d20+4** direct magic damage. On a success, they take half damage and lose a Hope." + }, + { + "name": "Summon Tormentors - Action", + "text": "Once per day, **spend 2 Fear** to summon **2d4** Tier 2 or below Minions relevant to one of the PC's personal nightmares. They appear at Close range relative to that PC." + }, + { + "name": "Ominous Knowledge - Reaction", + "text": "When the Oracle sees a mortal creature, they instantly know one of their personal nightmares." + }, + { + "name": "Vengeful Fate - Reaction", + "text": "When the Oracle marks HP from an attack within Very Close range, you can **mark a Stress** to knock the attacker back to Far range and deal **2d10+4** physical damage." + } + ], + "hp": "11", + "motives_and_tactics": "Change environment, condemn, dishearten, toss aside", + "name": "Oracle of Doom", + "range": "Far", + "stress": "10", + "thresholds": "38/68", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+2d4", + "attack": "Massive Pseudopod", + "damage": "4d6+13 mag", + "description": "A chaotic mockery of life, constantly in flux.", + "difficulty": "19", + "feature": [ + { + "name": "Chaotic Form - Passive", + "text": "When the Abomination attacks, roll **2d4** and use the result as their attack modifier." + }, + { + "name": "Disorienting Presence - Passive", + "text": "When a target takes damage from the Abomination, they must make an Instinct Reaction Roll. On a failure, they gain disadvantage on their next action roll and you gain a Fear." + }, + { + "name": "Reality Quake - Action", + "text": "**Spend a Fear** to rattle the edges of reality within Far range of the Abomination. All targets within that area must succeed on a Knowledge Reaction Roll or become _Unstuck_ from reality until the end of the scene. When an _Unstuck_ target spends Hope or marks Armor Slots, HP, or Stress, they must double the amount spent or marked." + }, + { + "name": "Unreal Form - Reaction", + "text": "When the Abomination takes damage, reduce it by **1d20**. If the Abomination marks 1 or fewer Hit Points from a successful attack against them, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Demolish, devour, undermine", + "name": "Outer Realms Abomination", + "range": "Very Close", + "stress": "5", + "thresholds": "35/71", + "tier": "4", + "type": "Bruiser" + }, + { + "atk": "+7", + "attack": "Corroding Pseudopod", + "damage": "4d8+5 mag", + "description": "A shifting, formless mass seemingly made of chromatic light.", + "difficulty": "19", + "feature": [ + { + "name": "Will-Shattering Touch - Passive", + "text": "When a PC takes damage from the Corruptor, they lose a Hope." + }, + { + "name": "Disgorge Reality Flotsam - Action", + "text": "**Mark a Stress** to spew partially digested portions of consumed realities at all targets within Close range. Targets must succeed on a Knowledge Reaction Roll or mark 2 Stress." + } + ], + "hp": "4", + "motives_and_tactics": "Confuse, distract, overwhelm", + "name": "Outer Realms Corruptor", + "range": "Very Close", + "stress": "3", + "thresholds": "27/47", + "tier": "4", + "type": "Support" + }, + { + "atk": "+3", + "attack": "Claws and Teeth", + "damage": "11 phy", + "description": "A vaguely humanoid form stripped of memory and identity.", + "difficulty": "17", + "feature": [ + { + "name": "Minion (13) - Passive", + "text": "The Thrall is defeated when they take any damage. For every 13 damage a PC deals to the Thrall, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Outer Realm Thralls within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 11 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Destroy, disgust, disorient, intimidate", + "name": "Outer Realms Thrall", + "range": "Very Close", + "stress": "1", + "thresholds": "None", + "tier": "4", + "type": "Minion" + }, + { + "atk": "+8", + "attack": "Obsidian Claws", + "damage": "4d10+4 phy", + "description": "A massive winged creature with obsidian scales and impossibly sharp claws.", + "difficulty": "19", + "experience": "Hunt from Above +5", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Obsidian Predator can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Flying - Passive", + "text": "While flying, the Obsidian Predator gains a +3 bonus to their Difficulty." + }, + { + "name": "Obsidian Scales - Passive", + "text": "The Obsidian Predator is resistant to physical damage." + }, + { + "name": "Avalanche Tail - Action", + "text": "**Mark a Stress** to make an attack against all targets within Close range. Targets the Obsidian Predator succeeds against take **4d6+4** physical damage and are knocked back to Far range and _Vulnerable_ until their next roll with Hope." + }, + { + "name": "Dive-Bomb - Action", + "text": "If the Obsidian Predator is flying, **mark a Stress** to choose a point within Far range. Move to that point and make an attack against all targets within Very Close range. Targets the Obsidian Predator succeeds against take **2d10+6** physical damage and must mark a Stress and lose a Hope." + }, + { + "name": "Erupting Rage (Phase Change) - Reaction", + "text": "When the Obsidian Predator marks their last HP, replace them with the Molten Scourge and immediately spotlight them." + } + ], + "hp": "6", + "motives_and_tactics": "Defend lair, dive-bomb, fly, hunt, intimidate", + "name": "Volcanic Dragon: Obsidian Predator", + "range": "Close", + "stress": "5", + "thresholds": "33/65", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+9", + "attack": "Lava-Coated Claws", + "damage": "4d12+4 phy", + "description": "Enraged by their wounds, the dragon bursts into molten lava.", + "difficulty": "20", + "experience": "Hunt from Above +5", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Molten Scourge can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Cracked Scales - Passive", + "text": "When the Molten Scourge takes damage, roll a number of **d6s** equal to HP marked. For each result of 4 or higher, you gain a Fear." + }, + { + "name": "Shattering Might - Action", + "text": "**Mark a Stress** to make an attack against a target within Very Close range. On a success, the target takes **4d8+1** physical damage, loses a Hope, and is knocked back to Close range. The Molten Scourge clears a Stress." + }, + { + "name": "Eruption - Action", + "text": "**Spend a Fear** to erupt lava from beneath the Molten Scourge's scales, filling the area within Very Close range with molten lava. All targets in that area must succeed on an Agility Reaction Roll or take **4d6+6** physical damage and be knocked back to Close range. This area remains lava. When a creature other than the Molten Scourge enters that area or acts while inside of it, they must mark 6 HP." + }, + { + "name": "Volcanic Breath - Reaction", + "text": "When the Molten Scourge takes Major damage, roll a **d10**. On a result of 8 or higher, the Molten Scourge breathes a flow of lava in front of them within Far range. All targets in that area must make an Agility Reaction Roll. Targets who fail take **2d10+4** physical damage, mark **1d4 Stress**, and are _Vulnerable_ until they clear a Stress. Targets who succeed take half damage and must mark a Stress." + }, + { + "name": "Lava Splash - Reaction", + "text": "When the Molten Scourge takes Severe damage from an attack within Very Close range, molten blood gushes from the wound and deals **2d10+4** direct physical damage to the attacker." + }, + { + "name": "Ashen Vengeance (Phase Change) - Reaction", + "text": "When the Molten Scourge marks their last HP, replace them with the Ashen Tyrant and immediately spotlight them." + } + ], + "hp": "7", + "motives_and_tactics": "Douse with lava, incinerate, repel Invaders, reposition", + "name": "Volcanic Dragon: Molten Scourge", + "range": "Close", + "stress": "5", + "thresholds": "30/58", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+10", + "attack": "Claws and Teeth", + "damage": "4d12+15 phy", + "description": "No enemy has ever had the insolence to wound the dragon so. As the lava settles, it's ground to ash like the dragon's past foes.", + "difficulty": "18", + "experience": "Hunt from Above +5", + "feature": [ + { + "name": "Relentless (4) - Passive", + "text": "The Ashen Tyrant can be spotlighted up to four times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Cornered - Passive", + "text": "**Mark a Stress** instead of spending a Fear to spotlight the Ashen Tyrant." + }, + { + "name": "Injured Wings - Passive", + "text": "While flying, the Ashen Tyrant gains a +1 bonus to their Difficulty." + }, + { + "name": "Ashes to Ashes - Passive", + "text": "When a PC rolls a failure while within Close range of the Ashen Tyrant, they lose a Hope and you gain a Fear. If the PC can't lose a Hope, they must mark a HP." + }, + { + "name": "Desperate Rampage - Action", + "text": "**Mark a Stress** to make an attack against all targets within Close range. Targets the Ashen Tyrant succeeds against take **2d20+2** physical damage, are knocked back to Close range of where they were, and must mark a Stress." + }, + { + "name": "Ashen Cloud - Action", + "text": "**Spend a Fear** to smash the ground and kick up ash within Far range. While within the ash cloud, a target has disadvantage on action rolls. The ash cloud clears the next time an adversary is spotlighted." + }, + { + "name": "Apocalyptic Thrashing - Action: Countdown (1d12)", + "text": "**Spend a Fear** to activate. It ticks down when a PC rolls with Fear. When it triggers, the Ashen Tyrant thrashes about, causing environmental damage (such as an earthquake, avalanche, or collapsing walls). All targets within Far range must make a Strength Reaction Roll. Targets who fail take **2d10+10** physical damage and are _Restrained_ by the rubble until they break free with a successful Strength Roll. Targets who succeed take half damage. If the Ashen Tyrant is defeated while this countdown is active, trigger the countdown immediately as the destruction caused by their death throes." + } + ], + "hp": "8", + "motives_and_tactics": "Choke, fly, intimidate, kill or be killed", + "name": "Volcanic Dragon: Ashen Tyrant", + "range": "Close", + "stress": "5", + "thresholds": "29/55", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+4", + "attack": "Greataxe", + "damage": "4d12+15 phy", + "description": "A towering, muscular zombie with magically infused strength and skill.", + "difficulty": "20", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Zombie makes a successful attack, all PCs within Far range lose a Hope and you gain a Fear." + }, + { + "name": "Fearsome Presence - Passive", + "text": "PCs can't spend Hope to use features against the Zombie." + }, + { + "name": "Perfect Strike - Action", + "text": "**Mark a Stress** to make a standard attack against all targets within Very Close range. Targets the Zombie succeeds against are _Vulnerable_ until their next rest." + }, + { + "name": "Skilled Opportunist - Reaction", + "text": "When another adversary deals damage to a target within Very Close range of the Zombie, you can **spend a Fear** to add the Zombie's standard attack damage to the damage roll." + } + ], + "hp": "9", + "motives_and_tactics": "Consume, hound, maim, terrify", + "name": "Perfected Zombie", + "range": "Very Close", + "stress": "4", + "thresholds": "40/70", + "tier": "4", + "type": "Bruiser" + }, + { + "atk": "+2", + "attack": "Undead Hands", + "damage": "4d6+10 phy", + "description": "A large pack of undead, still powerful despite their rotting flesh.", + "difficulty": "17", + "feature": [ + { + "name": "Horde (2d6+5) - Passive", + "text": "When the Legion has marked half or more of their HP, their standard attack deals **2d6+5** physical damage instead." + }, + { + "name": "Unyielding - Passive", + "text": "The Legion has resistance to physical damage." + }, + { + "name": "Relentless (2) - Passive", + "text": "The Legion can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Overwhelm - Reaction", + "text": "When the Legion takes Minor damage from an attack within Melee range, you can **mark a Stress** to make a standard attack with advantage against the attacker." + } + ], + "hp": "8", + "motives_and_tactics": "Consume brain, shred flesh, surround", + "name": "Zombie Legion", + "range": "Close", + "stress": "5", + "thresholds": "25/45", + "tier": "4", + "type": "Horde (3/HP)" + } +] \ No newline at end of file diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/Resources/environments.json b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/Resources/environments.json new file mode 100644 index 0000000..4b46938 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/Resources/environments.json @@ -0,0 +1,601 @@ +[ + { + "description": "A former druidic grove lying fallow and fully reclaimed by nature.", + "difficulty": "11", + "feature": [ + { + "name": "Overgrown Battlefield - Passive", + "question": "Why did these groups come to blows? Why is the grove unused now?", + "text": "There has been a battle here. A PC can make an Instinct Roll to identify evidence of that fight. On a success with Hope, learn all three pieces of information below. On a success with Fear, learn two. On a failure, a PC can mark a Stress to learn one and gain advantage on the next action roll to investigate this environment. A PC with an appropriate background or Experience can learn an additional detail and ask a follow-up question about the scene and get a truthful (if not always complete) answer. _Why did these groups come to blows? Why is the grove unused now?_\n\n- Traces of a battle (broken weapons and branches, gouges in the ground) litter the ground.\n- A moss-covered tree trunk is actually the corpse of a treant.\n- Still-standing trees are twisted in strange ways, as if by powerful magic." + }, + { + "name": "Barbed Vines - Action", + "question": "How many vines are there? Where do they grab you? Do they pull you down or lift you off the ground?", + "text": "Pick a point within the grove. All targets within Very Close range of that point must succeed on an Agility Reaction Roll or take **1d8+3** physical damage and become _Restrained_ by barbed vines. _Restrained_ lasts until they're freed with a successful Finesse or Strength roll or by dealing at least 6 damage to the vines. _How many vines are there? Where do they grab you? Do they pull you down or lift you off the ground?_" + }, + { + "name": "You Are Not Welcome Here - Action", + "question": "What are the grove guardians concealing? What threat to the forest could the PCs confront to appease the Dryad?", + "text": "A Young Dryad, two Sylvan Soldiers, and a number of Minor Treants equal to the number of PCs appear to confront the party for their intrusion. _What are the grove guardians concealing? What threat to the forest could the PCs confront to appease the Dryad?_" + }, + { + "name": "Defiler - Action", + "question": "What color does the grass turn as the elemental appears? How does the chaos warp insects and small wildlife within the grove?", + "text": "**Spend a Fear** to summon a Minor Chaos Elemental drawn to the echoes of violence and discord. They appear within Far range of a chosen PC and immediately take the spotlight. _What color does the grass turn as the elemental appears? How does the chaos warp insects and small wildlife within the grove?_" + } + ], + "impulses": "Draw in the curious, echo the past", + "name": "Abandoned Grove", + "potential_adversaries": "Beasts (Bear, Dire Wolf, Glass Snake), Grove Guardians (Minor Treant, Sylvan Soldier, Young Dryad)", + "tier": "1", + "type": "Exploration" + }, + { + "description": "An ambush is set to catch an unsuspecting party off-guard.", + "difficulty": "Special (see \"Relative Strength\")", + "feature": [ + { + "name": "Relative Strength - Passive", + "question": "Who cues the ambush? What makes it clear they're in charge?", + "text": "The Difficulty of this environment equals that of the adversary with the highest Difficulty. _Who cues the ambush? What makes it clear they're in charge?_" + }, + { + "name": "Surprise! - Action", + "question": "What do the ambushers want from the party? How do their tactics in the ambush reflect that?", + "text": "The ambushers reveal themselves to the party, you gain 2 Fear, and the spotlight immediately shifts to one of the ambushing adversaries. _What do the ambushers want from the party? How do their tactics in the ambush reflect that?_" + } + ], + "impulses": "Overwhelm, scatter, surround", + "name": "Ambushed", + "potential_adversaries": "Any", + "tier": "1", + "type": "Event" + }, + { + "description": "An ambush is set by the PCs to catch unsuspecting adversaries off-guard.", + "difficulty": "Special (see \"Relative Strength\")", + "feature": [ + { + "name": "Relative Strength - Passive", + "question": "Which adversary is the least prepared? Which one is the most?", + "text": "The Difficulty of this environment equals that of the adversary with the highest Difficulty. _Which adversary is the least prepared? Which one is the most?_" + }, + { + "name": "Where Did They Come From? - Reaction", + "question": "What are the adversaries in the middle of doing when the ambush starts? How does this impact their approach to the fight?", + "text": "When a PC starts the ambush on unsuspecting adversaries, you lose 2 Fear and the first attack roll a PC makes has advantage. _What are the adversaries in the middle of doing when the ambush starts? How does this impact their approach to the fight?_" + } + ], + "impulses": "Escape, group up, protect the most vulnerable", + "name": "Ambushers", + "potential_adversaries": "Any", + "tier": "1", + "type": "Event" + }, + { + "description": "The economic heart of the settlement, with local artisans, traveling merchants, and patrons across social classes.", + "difficulty": "10", + "feature": [ + { + "name": "Tip the Scales - Passive", + "question": "How overt are the PCs in offering this bribe?", + "text": "PCs can gain advantage on a Presence Roll by offering a handful of gold as part of the interaction. _Will any coin be accepted, or only local currency? How overt are the PCs in offering this bribe?_" + }, + { + "name": "Unexpected Find - Action", + "question": "What cost beyond gold will the merchant ask for in exchange for this rarity?", + "text": "Reveal to the PCs that one of the merchants has something they want or need, such as food from their home, a rare book, magical components, a dubious treasure map, or a magical key. _What cost beyond gold will the merchant ask for in exchange for this rarity?_" + }, + { + "name": "Sticky Fingers - Action", + "question": "What drove this person to pickpocketing? Where is the thief's hideout and how has it avoided notice?", + "text": "A thief tries to steal something from a PC. The PC must succeed on an Instinct Roll to notice the thief or lose an item of the GM's choice as the thief escapes to a Close distance. To retrieve the stolen item, the PCs must complete a Progress Countdown (6) to chase down the thief before the thief completes a Consequence Countdown (4) and escapes to their hideout. _What drove this person to pickpocketing? Where is the thief's hideout and how has it avoided notice?_" + }, + { + "name": "Crowd Closes In - Reaction", + "question": "Where does the crowd's movement carry them? How do they feel about being alone but surrounded?", + "text": "When one of the PCs splits from the group, the crowds shift and cut them off from the party. _Where does the crowd's movement carry them? How do they feel about being alone but surrounded?_" + } + ], + "impulses": "Buy low, and sell high, tempt and tantalize with wares from near and far", + "name": "Bustling Marketplace", + "potential_adversaries": "Guards (Bladed Guard, Head Guard), Masked Thief, Merchant", + "tier": "1", + "type": "Social" + }, + { + "description": "A steep, rocky cliff side tall enough to make traversal dangerous.", + "difficulty": "12", + "feature": [ + { + "name": "The Climb - Passive", + "question": "What strange formations are the stones arranged in? What ominous warnings did previous adventurers leave?", + "text": "Climbing up the cliff side uses a Progress Countdown (12). It ticks down according to the following criteria when the PCs make an action roll to climb:\n\n- **Critical Success:** Tick down 3\n- **Success with Hope:** Tick down 2\n- **Success with Fear:** Tick down 1\n- **Failure with Hope:** No advancement\n- **Failure with Fear:** Tick up 1\n\nWhen the countdown triggers, the party has made it to the top of the cliff. _What strange formations are the stones arranged in? What ominous warnings did previous adventurers leave?_" + }, + { + "name": "Pitons Left Behind - Passive", + "question": "What do the shape and material of these pitons tell you about the previous climbers? How far apart are they from one another?", + "text": "Previous climbers left behind large metal rods that climbers can use to aid their ascent. If a PC using the pitons fails an action roll to climb, they can mark a Stress instead of ticking the countdown up. _What do the shape and material of these pitons tell you about the previous climbers? How far apart are they from one another?_" + }, + { + "name": "Fall - Action", + "question": "How can you tell many others have fallen here before? What lives in these walls that might try to scare adventurers into falling for an easy meal?", + "text": "**Spend a Fear** to have a PC's handhold fail, plummeting them toward the ground. If they aren't saved on the next action, they hit the ground and tick up the countdown by 2. The PC takes **1d12** physical damage if the countdown is between 8 and 12, **2d12** between 4 and 7, and **3d12** at 3 or lower. _How can you tell many others have fallen here before? What lives in these walls that might try to scare adventurers into falling for an easy meal?_" + } + ], + "impulses": "Cast the unready down to a rocky doom, draw people in with promise of what lies at the top", + "name": "Cliffside Ascent", + "potential_adversaries": "Construct, Deeproot Defender, Giant Scorpion, Glass Snake", + "tier": "1", + "type": "Traversal" + }, + { + "description": "A lively tavern that serves as the social hub for its town.", + "difficulty": "10", + "feature": [ + { + "name": "What's the Talk of the Town? - Passive", + "question": "Who has what kind of information? What gossip do the locals start spreading about the PCs?", + "text": "A PC can ask the bartender, staff, or patrons about local events, rumors, and potential work with a Presence Roll. On a success, they can pick two of the below details to learn—or three if they critically succeed. On a failure, they can pick one and mark a Stress as the local carries on about something irrelevant. _Who has what kind of information? What gossip do the locals start spreading about the PCs?_\n\n- A fascinating rumor with a connection to a PC's background\n- A promising job for the party involving a nearby threat or situation\n- Local folklore that relates to something they've seen\n- Town gossip that hints at a community problem" + }, + { + "name": "Sing For Your Supper - Passive", + "question": "What piece do you perform? What does that piece mean to you? When's the last time you performed it for a crowd?", + "text": "A PC can perform one time for the guests by making a Presence Roll. On a success, they earn **1d4** handfuls of gold (**2d4** if they critically succeed). On a failure, they mark a Stress. _What piece do you perform? What does that piece mean to you? When's the last time you performed it for a crowd?_" + }, + { + "name": "Mysterious Stranger - Action", + "question": "What do they want? What's their impression of the PCs? What mannerisms or accessories do they have?", + "text": "Reveal a stranger concealing their identity, lurking in a shaded booth. _What do they want? What's their impression of the PCs? What mannerisms or accessories do they have?_" + }, + { + "name": "Someone Comes to Town - Action", + "question": "Did they know the PCs were here? What do they want in this town?", + "text": "Introduce a significant NPC who wants to hire the party for something or who relates to a PC's background. _Did they know the PCs were here? What do they want in this town?_" + }, + { + "name": "Bar Fight! - Action", + "question": "Who started the fight? What will it take to stop it?", + "text": "**Spend a Fear** to have a bar fight erupt in the tavern. When a PC tries to move through the tavern while the fight persists, they must succeed on an Agility or Presence Roll or take **1d6+2** physical damage from a wild swing or thrown object. A PC can try to activate this feature by succeeding on an action roll that would provoke tavern patrons. _Who started the fight? What will it take to stop it?_" + } + ], + "impulses": "Provide opportunities for adventurers, nurture community", + "name": "Local Tavern", + "potential_adversaries": "Guards (Bladed Guard, Head Guard), Mercenaries (Harrier, Sellsword, Spellblade, Weaponmaster), Merchant", + "tier": "1", + "type": "Social" + }, + { + "description": "A small town on the outskirts of a nation or region, close to a dungeon, tombs, or other adventuring destinations.", + "difficulty": "12", + "feature": [ + { + "name": "Rumors Abound - Passive", + "question": "What news do the PCs have that they could pass along to curious travelers? What do the locals think about these events?", + "text": "Gossip is the fastest-traveling currency in the realm. A PC can inquire about major events by making a Presence Roll. What they learn depends on the outcome of their roll, based on the following criteria. _What news do the PCs have that they could pass along to curious travelers? What do the locals think about these events?_\n\n- **Critical Success:** Learn about two major events. The PC can ask one follow-up question about one of the rumors and get a truthful (if not always complete) answer.\n- **Success with Hope:** Learn about two events, at least one of which is relevant to the character's background.\n- **Success with Fear:** Learn an alarming rumor related to the character's background.\n- **Any Failure:** The locals respond poorly to their inquiries. The PC must mark a Stress to learn one relevant rumor." + }, + { + "name": "Society of the Broken Compass - Passive", + "question": "What boasts do the adventurers here make, and which do you think are true?", + "text": "An adventuring society maintains a chapterhouse here, where heroes trade boasts and rumors, drink to their imagined successes, and scheme to undermine their rivals. _What boasts do the adventurers here make, and which do you think are true?_" + }, + { + "name": "Rival Party - Passive", + "question": "Which PC has a connection to one of the rival party members? Do they approach the PC first or do they wait for the PC to move?", + "text": "Another adventuring party is here, seeking the same treasure or leads as the PCs. _Which PC has a connection to one of the rival party members? Do they approach the PC first or do they wait for the PC to move?_" + }, + { + "name": "It'd Be a Shame If Something Happened to Your Store - Action", + "question": "What trouble does it cause if the PCs intervene?", + "text": "The PCs witness as agents of a local crime boss shake down a general goods store. _What trouble does it cause if the PCs intervene?_" + }, + { + "name": "Wrong Place, Wrong Time - Reaction", + "question": "What details show the party that these people are desperate former adventurers?", + "text": "At night, or when the party is alone in a back alley, you can **spend a Fear** to introduce a group of thieves who try to rob them. The thieves appear at Close range of a chosen PC and include a Jagged Knife Kneebreaker, as many Lackeys as there are PCs, and a Lieutenant. For a larger party, add a Hexer or Sniper. _What details show the party that these people are desperate former adventurers?_" + } + ], + "impulses": "Drive the desperate to certain doom, profit off of ragged hope", + "name": "Outpost Town", + "potential_adversaries": "Jagged Knife Bandits (Hexer, Kneebreaker, Lackey, Lieutenant, Shadow, Sniper), Masked Thief, Merchant", + "tier": "1", + "type": "Social" + }, + { + "description": "A swift-moving river without a bridge crossing, deep enough to sweep away most people.", + "difficulty": "10", + "feature": [ + { + "name": "Dangerous Crossing - Passive", + "question": "Are any of them afraid of drowning?", + "text": "Crossing the river requires the party to complete a Progress Countdown (4). A PC who rolls a failure with Fear is immediately targeted by the \"Undertow\" action without requiring a Fear to be spent on the feature. _Have any of the PCs forded rivers like this before? Are any of them afraid of drowning?_" + }, + { + "name": "Undertow - Action", + "question": "What trinkets and baubles lie along the bottom of the riverbed? Do predators swim these rivers?", + "text": "**Spend a Fear** to catch a PC in the undertow. They must make an Agility Reaction Roll. On a failure, they take **1d6+1** physical damage and are moved a Close distance down the river, becoming _Vulnerable_ until they get out of the river. On a success, they must mark a Stress. _What trinkets and baubles lie along the bottom of the riverbed? Do predators swim these rivers?_" + }, + { + "name": "Patient Hunter - Action", + "question": "What treasures does the beast have in their burrow? What travelers have already fallen victim to this predator?", + "text": "**Spend a Fear** to summon a Glass Snake within Close range of a chosen PC. The Snake appears in or near the river and immediately takes the spotlight to use their \"Spinning Serpent\" action. _What treasures does the beast have in their burrow? What travelers have already fallen victim to this predator?_" + } + ], + "impulses": "Bar crossing, carry away the unready, divide the land", + "name": "Raging River", + "potential_adversaries": "Beasts (Bear, Glass Snake), Jagged Knife Bandits (Hexer, Kneebreaker, Lackey, Lieutenant, Shadow, Sniper)", + "tier": "1", + "type": "Traversal" + }, + { + "description": "A Fallen cult assembles around a sigil of the defeated gods and a bonfire that burns a sickly shade of green.", + "difficulty": "14", + "feature": [ + { + "name": "Desecrated Ground - Passive", + "question": "How do the PCs first notice that something is wrong about this place? What fears resurface while hope is kept at bay?", + "text": "Cultists dedicated this place to the Fallen Gods, and their foul influence seeps into it. Reduce the PCs' Hope Die to a **d10** while in this environment. The desecration can be removed with a Progress Countdown (6). _How do the PCs first notice that something is wrong about this place? What fears resurface while hope is kept at bay?_" + }, + { + "name": "Blasphemous Might - Action", + "question": "How does the enemy change in appearance? What fears do their blows bring to the surface?", + "text": "A portion of the ritual's power is diverted into a cult member to fight off interlopers. Choose one adversary to become _Imbued_ with terrible magic until the scene ends or they're defeated. An _Imbued_ adversary immediately takes the spotlight and gains one of the following benefits, or all three if you **spend a Fear**:\n\n- They gain advantage on all attacks.\n- They deal an extra **1d10** damage on a successful attack.\n- They gain the following feature: _Relentless (2) - Passive:_ This adversary can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them. _How does the enemy change in appearance? What fears do their blows bring to the surface?_" + }, + { + "name": "The Summoning - Reaction: Countdown (6)", + "question": "What will the cult do with this leashed demon if they succeed? What will they try to summon next?", + "text": "When the PCs enter the scene or the cult begins the ritual to summon a demon, activate the countdown. Designate one adversary to lead the ritual. The countdown ticks down when a PC rolls with Fear. When it triggers, summon a Minor Demon within Very Close range of the ritual's leader. If the leader is defeated, the countdown ends with no effect as the ritual fails. _What will the cult do with this leashed demon if they succeed? What will they try to summon next?_" + }, + { + "name": "Complete the Ritual - Reaction", + "question": "What does it feel like to see such devotion turned to the pursuit of fear and domination?", + "text": "If the ritual's leader is targeted by an attack or spell, an ally within Very Close range of them can **mark a Stress** to be targeted by that attack or spell instead. _What does it feel like to see such devotion turned to the pursuit of fear and domination?_" + } + ], + "impulses": "Profane the land, unite the Mortal Realm with the Circles Below", + "name": "Cult Ritual", + "potential_adversaries": "Cult of the Fallen (Cult Adept, Cult Fang, Cult Initiate, Secret-Keeper)", + "tier": "2", + "type": "Event" + }, + { + "description": "A bustling but well-kept temple that provides healing and hosts regular services, overseen by a priest or seraph.", + "difficulty": "13", + "feature": [ + { + "name": "A Place of Healing - Passive", + "question": "What does the incense smell like? What kinds of songs do the acolytes sing?", + "text": "A PC who takes a rest in the Hallowed Temple automatically clears all HP. _What does the incense smell like? What kinds of songs do the acolytes sing?_" + }, + { + "name": "Divine Guidance - Passive", + "question": "What does it feel like as you are touched by this vision? What feeling lingers after the images have passed?", + "text": "A PC who prays to a deity while in the Hallowed Temple can make an Instinct Roll to receive answers. If the god they beseech isn't welcome in this temple, the roll is made with disadvantage. _What does it feel like as you are touched by this vision? What feeling lingers after the images have passed?_\n\n- **Critical Success:** The PC gains clear information. Additionally, they gain **1d4** Hope, which can be distributed between the party if they share the vision and guidance they received.\n- **Success with Hope:** The PC receives clear information.\n- **Success with Fear:** The PC receives brief flashes of insight and an emotional impression conveying an answer.\n- **Any Failure:** The PC receives only vague flashes. They can mark a Stress to receive one clear image without context." + }, + { + "name": "Relentless Hope - Reaction", + "question": "What emotions or memories do you connect with when fear presses in?", + "text": "Once per scene, each PC can mark a Stress to turn a result with Fear into a result with Hope. _What emotions or memories do you connect with when fear presses in?_" + }, + { + "name": "Divine Censure - Reaction", + "question": "What symbols or icons do they bear that signal they are anointed agents of the divinity? Who leads the group and what led them to this calling?", + "text": "When the PCs have trespassed, blasphemed, or off ended the clergy, you can **spend a Fear** to summon a High Seraph and **1d4** Bladed Guards within Close range of the senior priest to reinforce their will. _What symbols or icons do they bear that signal they are anointed agents of the divinity? Who leads the group and what led them to this calling?_" + } + ], + "impulses": "Connect the Mortal Realm with the Hallows Above, display the power of the divine, provide aid and succor to the faithful", + "name": "Hallowed Temple", + "potential_adversaries": "Guards (Archer Guard, Bladed Guard, Head Guard)", + "tier": "2", + "type": "Social" + }, + { + "description": "An abandoned city populated by the restless spirits of eras past.", + "difficulty": "14", + "feature": [ + { + "name": "Buried Knowledge - Passive", + "question": "What greater secrets does the city contain? Why have so many ghosts lingered here? What doomed adventurers have met a bad fate here already?", + "text": "The city has countless mysteries to unfold. A PC who seeks knowledge about the fallen city can make an Instinct or Knowledge Roll to learn about this place and discover (potentially haunted) loot. _What greater secrets does the city contain? Why have so many ghosts lingered here? What doomed adventurers have met a bad fate here already?_\n\n- **Critical Success:** Gain valuable information and a related useful item.\n- **Success with Hope:** Gain valuable information.\n- **Success with Fear:** Uncover vague or incomplete information.\n- **Any Failure:** Mark a Stress to find a lead after an exhaustive search." + }, + { + "name": "Ghostly Form - Passive", + "question": "What injuries to their physical form speak to their cause of death? What unfulfilled purpose holds them in the Mortal Plane?", + "text": "Adversaries who appear here are of a ghostly form. They have resistance to physical damage and can **mark a Stress** to move up to Close range through solid objects. _What injuries to their physical form speak to their cause of death? What unfulfilled purpose holds them in the Mortal Plane?_" + }, + { + "name": "Dead Ends - Action", + "question": "What do the ghosts want from you? What do you need from them?", + "text": "The ghosts of an earlier era manifest scenes from their bygone era, such as a street festival, a revolution, or a heist. These hauntings change the layout of the city around the PCs, blocking the way behind them, forcing a detour, or presenting them with a challenge, such as mistaking them for rival thieves during the heist. _What do the ghosts want from you? What do you need from them?_" + }, + { + "name": "Apocalypse Then - Action", + "question": "Is this the disaster that led the city to be abandoned? What is known about this disaster, and how could that help the PCs escape?", + "text": "**Spend a Fear** to manifest the echo of a past disaster that ravaged the city. Activate a Progress Countdown (5) as the disaster replays around the PCs. To complete the countdown and escape the catastrophe, the PCs must overcome threats such as rampaging fires, stampeding civilians, collapsing buildings, or crumbling streets, while recalling history and finding clues to escape the inevitable. _Is this the disaster that led the city to be abandoned? What is known about this disaster, and how could that help the PCs escape?_" + } + ], + "impulses": "Misdirect and disorient, replay apocalypses both public and personal", + "name": "Haunted City", + "potential_adversaries": "Ghosts (Spectral Archer, Spectral Captain, Spectral Guardian), ghostly versions of other adversaries (see \"Ghostly Form\")", + "tier": "2", + "type": "Exploration" + }, + { + "description": "Stony peaks that pierce the clouds, with a twisting path winding its way up and over through many switchbacks.", + "difficulty": "15", + "feature": [ + { + "name": "Engraved Sigils - Passive", + "question": "Who laid this enchantment? Are they nearby? Why did they want the weather to be more daunting?", + "text": "Large markings and engravings have been made in the mountainside. A PC with a relevant background or Experience identifies them as weather magic increasing the power of the icy winds. A PC who succeeds on a Knowledge Roll can recall information about the sigils, potential information about their creators, and the knowledge of how to dispel them. If a PC critically succeeds, they recognize that the sigils are of a style created by ridgeborne enchanters and they gain advantage on a roll to dispel the sigils. _Who laid this enchantment? Are they nearby? Why did they want the weather to be more daunting?_" + }, + { + "name": "Avalanche - Action", + "question": "How do the PCs try to weather the avalanche? What approach do the characters take to find one another when their companions go hurtling down the mountainside?", + "text": "**Spend a Fear** to carve the mountain with an icy torrent, causing an avalanche. All PCs in its path must succeed on an Agility or Strength Reaction Roll or be bowled over and carried down the mountain. A PC using rope, pitons, or other climbing gear gains advantage on this roll. Targets who fail are knocked down the mountain to Far range, take **2d20** physical damage, and must mark a Stress. Targets who succeed must mark a Stress. _How do the PCs try to weather the avalanche? What approach do the characters take to find one another when their companions go hurtling down the mountainside?_" + }, + { + "name": "Raptor Nest - Reaction", + "question": "How long has it been since the eagles last found prey? Do they have eggs in their nest, or unfledged young?", + "text": "When the PCs enter the raptors' hunting grounds, two Giant Eagles appear at Very Far range of a chosen PC, identifying the PCs as likely prey. _How long has it been since the eagles last found prey? Do they have eggs in their nest, or unfledged young?_" + }, + { + "name": "Icy Winds - Reaction: Countdown (Loop 4)", + "question": "What parts of the PC's bodies go numb first? How do they try to keep warm as they press forward?", + "text": "When the PCs enter the mountain pass, activate the countdown. When it triggers, all characters traveling through the pass must succeed on a Strength Reaction Roll or mark a Stress. A PC wearing clothes appropriate for extreme cold gains advantage on these rolls. _What parts of the PC's bodies go numb first? How do they try to keep warm as they press forward?_" + } + ], + "impulses": "Exact a chilling toll in supplies and stamina, reveal magical tampering, slow down travel", + "name": "Mountain Pass", + "potential_adversaries": "Beasts (Bear, Giant Eagle, Glass Snake), Chaos Skull, Minotaur Wrecker, Mortal Hunter", + "tier": "2", + "type": "Traversal" + }, + { + "description": "Thick indigo ash fills the air around a towering moss-covered tree that burns eternally with flames a sickly shade of blue.", + "difficulty": "16", + "feature": [ + { + "name": "Chaos Magic Locus - Passive", + "question": "What does it feel like to work magic in this chaos-touched place? What do you fear will happen if you lose control of the spell?", + "text": "When a PC makes a Spellcast Roll, they must roll two Fear Dice and take the higher result. _What does it feel like to work magic in this chaos-touched place? What do you fear will happen if you lose control of the spell?_" + }, + { + "name": "The Indigo Flame - Passive", + "question": "What Fallen cult corrupted these woods? What have they already done with the cursed wood and sap from this tree?", + "text": "PCs who approach the central tree can make a Knowledge Roll to try to identify the magic that consumed this environment. _What Fallen cult corrupted these woods? What have they already done with the cursed wood and sap from this tree?_\n\n- **On a success**: They learn three of the below details. On a success with Fear, they learn two.\n- **On a failure**: They can mark a Stress to learn one and gain advantage on the next action roll to investigate this environment.\n- **Details:** This is a result of Fallen magic. The corruption is spread through the ashen moss. It can be cleansed only by a ritual of nature magic with a Progress Countdown (8)." + }, + { + "name": "Grasping Vines - Action", + "question": "What painful memories do the vines bring to the surface as they pierce flesh?", + "text": "Animate vines bristling with thorns whip out from the underbrush to ensnare the PCs. A target must succeed on an Agility Reaction Roll or become _Restrained_ and _Vulnerable_ until they break free, clearing both conditions, with a successful Finesse or Strength Roll or by dealing 10 damage to the vines. When the target makes a roll to escape, they take **1d8+4** physical damage and lose a Hope. _What painful memories do the vines bring to the surface as they pierce flesh?_" + }, + { + "name": "Charcoal Constructs - Action", + "question": "Are these real animals consumed by the flame or merely constructs of the corrupting magic?", + "text": "Warped animals wreathed in indigo flame trample through a point of your choice. All targets within Close range of that point must make an Agility Reaction Roll. Targets who fail take **3d12+3** physical damage. Targets who succeed take half damage instead. _Are these real animals consumed by the flame or merely constructs of the corrupting magic?_" + }, + { + "name": "Choking Ash - Reaction: Countdown (Loop 6)", + "question": "What hallucinations does the ash induce? What incongruous taste does it possess?", + "text": "When the PCs enter the Burning Heart of the Woods, activate the countdown. When it triggers, all characters must make a Strength or Instinct Reaction Roll. Targets who fail take **4d6+5** direct physical damage. Targets who succeed take half damage. Protective masks or clothes give advantage on the reaction roll. _What hallucinations does the ash induce? What incongruous taste does it possess?_" + } + ], + "impulses": "Beat out an uncanny rhythm for all to follow, corrupt the woods", + "name": "Burning Heart of the Woods", + "potential_adversaries": "Beasts (Bear, Glass Snake), Elementals (Elemental Spark), Verdant Defenders (Dryad, Oak Treant, Stag Knight)", + "tier": "3", + "type": "Exploration" + }, + { + "description": "An active siege with an attacking force fighting to gain entry to a fortified castle.", + "difficulty": "17", + "feature": [ + { + "name": "Secret Entrance - Passive", + "question": "How do they get in without revealing the pathway to the attackers? Are any of the defenders monitoring this path?", + "text": "A PC can find or recall a secret way into the castle with a successful Instinct or Knowledge Roll. _How do they get in without revealing the pathway to the attackers? Are any of the defenders monitoring this path?_" + }, + { + "name": "Siege Weapons (Environment Change) - Action", + "question": "What siege weapons are being deployed? Are they magical, mundane, or a mixture of both? What defenses must the characters overcome to storm the castle?", + "text": "_Consequence Countdown (6)._ The attacking force deploys siege weapons to try to raze the defenders' fortifications. Activate the countdown when the siege begins (for a protracted siege, make this a long-term countdown instead). When it triggers, the defenders' fortifications have been breached and the attackers flood inside. You gain 2 Fear, then shift to the Pitched Battle environment and spotlight it. _What siege weapons are being deployed? Are they magical, mundane, or a mixture of both? What defenses must the characters overcome to storm the castle?_" + }, + { + "name": "Reinforcements! - Action", + "question": "Who are they targeting first? What formation do they take?", + "text": "Summon a Knight of the Realm, a number of Tier 3 Minions equal to the number of PCs, and two adversaries of your choice within Far range of a chosen PC as reinforcements. The Knight of the Realm immediately takes the spotlight. _Who are they targeting first? What formation do they take?_" + }, + { + "name": "Collateral Damage - Reaction", + "question": "What debris is scattered by the attack? What is broken by the strike that can't be easily mended?", + "text": "When an adversary is defeated, you can **spend a Fear** to have a stray attack from a siege weapon hit a point on the battlefield. All targets within Very Close range of that point must make an Agility Reaction Roll. _What debris is scattered by the attack? What is broken by the strike that can't be easily mended?_\n\n- Targets who fail take **3d8+3** physical or magic damage and must mark a Stress.\n- Targets who succeed must mark a Stress." + } + ], + "impulses": "Bleed out the will to fight, breach the walls, build tension", + "name": "Castle Siege", + "potential_adversaries": "Mercenaries (Harrier, Sellsword, Spellblade, Weaponmaster), Noble Forces (Archer Squadron, Conscript, Elite Soldier, Knight of the Realm)", + "tier": "3", + "type": "Event" + }, + { + "description": "A massive combat between two large groups of armed combatants.", + "difficulty": "17", + "feature": [ + { + "name": "Adrift on a Sea of Steel - Passive", + "question": "Do the combatants mistake you for the enemy or consider you interlopers? Can you tell the difference between friend and foe in the fray?", + "text": "Traversing a battlefield during an active combat is extremely dangerous. A PC must succeed on an Agility Roll to move at all, and can only go up to Close range on a success. If an adversary is within Melee range of them, they must mark a Stress to make an Agility Roll to move. _Do the combatants mistake you for the enemy or consider you interlopers? Can you tell the difference between friend and foe in the fray?_" + }, + { + "name": "Raze and Pillage - Action", + "question": "What is valuable here? Who is most vulnerable?", + "text": "The attacking force raises the stakes by lighting a fire, stealing a valuable asset, kidnapping an important person, or killing the populace. _What is valuable here? Who is most vulnerable?_" + }, + { + "name": "War Magic - Action", + "question": "What form does the attack take—fireball, raining acid, a storm of blades? What tactical objective is this attack meant to accomplish, and what comes next?", + "text": "**Spend a Fear** as a mage from one side uses large-scale destructive magic. Pick a point on the battlefield within Very Far range of the mage. All targets within Close range of that point must make an Agility Reaction Roll. Targets who fail take **3d12+8** magic damage and must mark a Stress. _What form does the attack take—fireball, raining acid, a storm of blades? What tactical objective is this attack meant to accomplish, and what comes next?_" + }, + { + "name": "Reinforcements! - Action", + "question": "Who are they targeting first? What formation do they take?", + "text": "Summon a Knight of the Realm, a number of Tier 3 Minions equal to the number of PCs, and two adversaries of your choice within Far range of a chosen PC as reinforcements. The Knight of the Realm immediately takes the spotlight. _Who are they targeting first? What formation do they take?_" + } + ], + "impulses": "Seize people, land, and wealth, spill blood for greed and glory", + "name": "Pitched Battle", + "potential_adversaries": "Mercenaries (Sellsword, Harrier, Spellblade, Weaponmaster), Noble Forces (Archer Squadron, Conscript, Elite Soldier, Knight of the Realm)", + "tier": "3", + "type": "Event" + }, + { + "description": "An otherworldly space where the laws of reality are unstable and dangerous.", + "difficulty": "20", + "feature": [ + { + "name": "Impossible Architecture - Passive", + "question": "What does it feel like to move in a space so alien to the Mortal Realm? What landmark or point do you fixate on to maintain your balance? What bizarre landmarks do you traverse on your journey?", + "text": "Up is down, down is right, right is starward. Gravity and directionality themselves are in flux, and any attempt to move through this realm is an odyssey unto itself, requiring a Progress Countdown (8). On a failure, a PC must mark a Stress in addition to the roll's other consequences. _What does it feel like to move in a space so alien to the Mortal Realm? What landmark or point do you fixate on to maintain your balance? What bizarre landmarks do you traverse on your journey?_" + }, + { + "name": "Everything You Are This Place Will Take from You - Action: Countdown (Loop 1d4)", + "question": "How does this place try to steal from you that which makes you legendary? What does it feel like to have this power taken from you?", + "text": "Activate the countdown. When it triggers, all PCs must succeed on a Presence Reaction Roll or their highest trait is temporarily reduced by **1d4** unless they mark a number of Stress equal to its value. Any lost trait points are regained if the PC critically succeeds or escapes the Chaos Realm. _How does this place try to steal from you that which makes you legendary? What does it feel like to have this power taken from you?_" + }, + { + "name": "Unmaking - Action", + "question": "What glimpse of other worlds do you catch while this place tries to unmake you? What core facet of your personality does the unmaking try to erase?", + "text": "**Spend a Fear** to force a PC to make a Strength Reaction Roll. On a failure, they take **4d10** direct magic damage. On a success, they must mark a Stress. _What glimpse of other worlds do you catch while this place tries to unmake you? What core facet of your personality does the unmaking try to erase?_" + }, + { + "name": "Outer Realms Predators - Action", + "question": "What half-consumed remnants of the shattered world do these monstrosities cast aside in pursuit of living flesh? What jagged reflections of former personhood do you catch between moments of unquestioning malice?", + "text": "**Spend a Fear** to summon an Outer Realms Abomination, an Outer Realms Corruptor, and **2d6** Outer Realms Thralls, who appear at Close range of a chosen PC in defiance of logic and causality. Immediately spotlight one of these adversaries, and you can **spend an additional Fear** to automatically succeed on that adversary's standard attack. _What half-consumed remnants of the shattered world do these monstrosities cast aside in pursuit of living flesh? What jagged reflections of former personhood do you catch between moments of unquestioning malice?_" + }, + { + "name": "Disorienting Reality - Reaction", + "question": "What moment do they see? How hard will it be to hold on to the real memory?", + "text": "On a result with Fear, you can ask the PC to describe which of their fears the Chaos Realm evokes as a vision of reality unmakes and reconstitutes itself to the PC. The PC loses a Hope. If it is their last Hope, you gain a Fear. _What moment do they see? If it's a memory, how is it warped by this place? How hard will it be to hold on to the real memory?_" + } + ], + "impulses": "Annihilate certainty, consume power, defy logic", + "name": "Chaos Realm", + "potential_adversaries": "Outer Realms Monstrosities (Abomination, Corruptor, Thrall)", + "tier": "4", + "type": "Traversal" + }, + { + "description": "A massive ritual designed to breach the gates of the Hallows Above and unseat the New Gods themselves.", + "difficulty": "20", + "feature": [ + { + "name": "Final Preparations - Passive", + "question": "What does the Usurper still require: The heart of a High Seraph?", + "text": "When the environment first takes the spotlight, designate one adversary as the Usurper seeking to overthrow the gods. Activate a Long-Term Countdown (8) as the Usurper assembles what they need to conduct the ritual. When it triggers, spotlight this environment to use the \"Beginning of the End\" feature. While this environment remains in play, you can hold up to 15 Fear. _What does the Usurper still require: The heart of a High Seraph? The lodestone of an ancient waygate? The loyalty of two archenemies? The heartbroken tears of a pure soul?_" + }, + { + "name": "Divine Blessing - Passive", + "question": "What god favors you as you fight against this usurpation? How does your renewed power reflect their influence?", + "text": "When a PC critically succeeds, they can spend 2 Hope to refresh an ability normally limited by uses (such as once per rest, once per session). _What god favors you as you fight against this usurpation? How does your renewed power reflect their influence?_" + }, + { + "name": "Defilers Abound - Action", + "question": "Which High Fallen do these troops serve? Which god's flesh do they wish to feast upon?", + "text": "**Spend 2 Fear** to summon **1d4+2** Fallen Shock Troops that appear within Close range of the Usurper to assist their divine siege. Immediately spotlight the Shock Troops to use a \"Group Attack\" action. _Which High Fallen do these troops serve? Which god's flesh do they wish to feast upon?_" + }, + { + "name": "Godslayer - Action", + "question": "Which god meets their end? What are their last words? How does the Usurper's new stolen power manifest?", + "text": "If the Divine Siege Countdown (see \"Beginning of the End\") has triggered, you can **spend 3 Fear** to describe the Usurper slaying one of the gods of the Hallows Above, feasting upon their power and growing stronger. The Usurper clears 2 HP. Increase their Difficulty, damage, attack modifier, or give them a new feature from the slain god. _Which god meets their end? What are their last words? How does the Usurper's new stolen power manifest?_" + }, + { + "name": "Beginning of the End - Reaction", + "question": "How does the Mortal Realm writhe as the natural order is violated? What mortals witness this blasphemy from afar?", + "text": "When the \"Final Preparations\" long-term countdown triggers, the Usurper begins hammering on the gates of the Hallows themselves. Activate a Divine Siege Countdown (10). Spotlight the Usurper to describe the Usurper's assault and tick down this countdown by 1. If the Usurper takes Major or greater damage, tick up the countdown by 1. When it triggers, the Usurper shatters the barrier between the Mortal Realm and the Hallows Above to slay the gods and take their place. You gain a Fear for each unmarked HP the Usurper has. You can immediately use the \"Godslayer\" feature without spending Fear to make an additional GM move. _How does the Mortal Realm writhe as the natural order is violated? What mortals witness this blasphemy from afar?_" + }, + { + "name": "Ritual Nexus - Reaction", + "question": "What visions of failures past torment you as your eff orts fall short? How are these memories twisted by the Usurper?", + "text": "On any failure with Fear against the Usurper, the PC must mark **1d4** Stress from the backlash of magical power. _What visions of failures past torment you as your eff orts fall short? How are these memories twisted by the Usurper?_" + } + ], + "impulses": "Collect power, overawe, silence dissent", + "name": "Divine Usurpation", + "potential_adversaries": "Arch-Necromancer, Fallen Shock Troops, Mortal Hunter, Oracle of Doom, Perfected Zombie", + "tier": "4", + "type": "Event" + }, + { + "description": "The majestic domain of a powerful empire, lavishly appointed with stolen treasures.", + "difficulty": "20", + "feature": [ + { + "name": "All Roads Lead Here - Passive", + "question": "How does the way language is used make even discussing alternative ways of living difficult? What obvious benefits for loyalty create friction when you try to discuss alternatives?", + "text": "While in the Imperial Court, a PC has disadvantage on Presence Rolls made to take actions that don't fit the imperial way of life or support the empire's dominance. _How does the way language is used make even discussing alternative ways of living difficult? What obvious benefits for loyalty create friction when you try to discuss alternatives?_" + }, + { + "name": "Rival Vassals - Passive", + "question": "How do they benefit from vassalage, and what has it cost them? What exploitation drives them to consider opposing the unstoppable?", + "text": "The PCs can find imperial subjects, vassals, and supplicants in the court, each vying for favor, seeking proximity to power, exchanging favors for loyalty, and elevating their status above others'. Some might be desperate to undermine their rivals, while others might even be open to discussions that verge on sedition. _How do they benefit from vassalage, and what has it cost them? What exploitation drives them to consider opposing the unstoppable?_" + }, + { + "name": "The Gravity of Empire - Action", + "question": "What do the PCs want so desperately they might consider throwing in with this ruthless power? How did imperial agents learn the PC's greatest desires?", + "text": "**Spend a Fear** to present a PC with a golden opportunity or offer to satisfy a major goal in exchange for obeying or supporting the empire. The target must make a Presence Reaction Roll. On a failure, they must mark all their Stress or accept the offer. If they have already marked all their Stress, they must accept the offer or exile themselves from the empire. On a success, they must mark **1d4** Stress as they're taxed by temptation. _What do the PCs want so desperately they might consider throwing in with this ruthless power? How did imperial agents learn the PC's greatest desires?_" + }, + { + "name": "Imperial Decree - Action", + "question": "What display of power or transfer of wealth was needed to expedite this plan?", + "text": "**Spend a Fear** to tick down a long-term countdown related to the empire's agenda by **1d4**. If this triggers the countdown, a proclamation related to the agenda is announced at court as the plan is executed. _What display of power or transfer of wealth was needed to expedite this plan? Whose lives were disrupted or upended to make this happen?_" + }, + { + "name": "Eyes Everywhere - Reaction", + "question": "How has the empire compromised this witness? Why is their first impulse to protect the empire, even if doesn't treat them well?", + "text": "On a result with Fear, you can **spend a Fear** to have someone loyal to the empire overhear seditious talk within the court. A PC must succeed on an Instinct Reaction Roll to notice that the group has been overheard so they can try to intercept the witness before the PCs are exposed. _How has the empire compromised this witness? Why is their first impulse to protect the empire, even if doesn't treat them well?_" + } + ], + "impulses": "Justify and perpetuate imperial rule, seduce rivals with promises of power and comfort", + "name": "Imperial Court", + "potential_adversaries": "Bladed Guard, Courtesan, Knight of the Realm, Monarch, Spy", + "tier": "4", + "type": "Social" + }, + { + "description": "A dusty crypt with a library, twisting corridors, and abundant sarcophagi, spattered with the blood of ill-fated invaders.", + "difficulty": "19", + "feature": [ + { + "name": "No Place for the Living - Passive", + "question": "What does it feel like to try to heal in a place so antithetical to life?", + "text": "A feature or action that clears HP requires spending a Hope to use. If it already costs Hope, a PC must spend an additional Hope. _What does it feel like to try to heal in a place so antithetical to life?_" + }, + { + "name": "Centuries of Knowledge - Passive", + "question": "What are the names of the tomes? What project is the necromancer working on and what does it communicate about their plans?", + "text": "A PC can investigate the library and laboratory and make a Knowledge Roll to learn information related to arcana, local history, and the Necromancer's plans. _What are the names of the tomes? What project is the necromancer working on and what does it communicate about their plans?_" + }, + { + "name": "Skeletal Burst - Action", + "question": "What ancient skeletal architecture is destroyed? What bones stick in your armor?", + "text": "All targets within Close range of a point you choose in this environment must succeed on an Agility Reaction Roll or take **4d8+8** physical damage from skeletal shrapnel as part of the ossuary detonates around them. _What ancient skeletal architecture is destroyed? What bones stick in your armor?_" + }, + { + "name": "Aura of Death - Action", + "question": "How does their renewed vigor manifest? Do they look more lifelike or, paradoxically, are they more decayed but vigorous?", + "text": "Once per scene, roll a **d4**. Each undead within Far range of the Necromancer can clear HP and Stress equal to the result rolled. The undead can choose how that number is divided between HP and Stress. _How does their renewed vigor manifest? Do they look more lifelike or, paradoxically, are they more decayed but vigorous?_" + }, + { + "name": "They Just Keep Coming! - Action", + "question": "Who were these people before they became the necromancer's pawns? What vestiges of those lives remain for the heroes to see?", + "text": "**Spend a Fear** to summon **1d6** Rotted Zombies, two Perfected Zombies, or a Zombie Legion, who appear at Close range of a chosen PC. _Who were these people before they became the necromancer's pawns? What vestiges of those lives remain for the heroes to see?_" + } + ], + "impulses": "Confound intruders, delve into secrets best left buried, manifest unlife, unleash a tide of undead", + "name": "Necromancer's Ossuary", + "potential_adversaries": "Arch-Necromancer's Host (Perfected Zombie, Zombie Legion)", + "tier": "4", + "type": "Exploration" + } +] \ No newline at end of file diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/SessionRegistry.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/SessionRegistry.swift new file mode 100644 index 0000000..9e090ea --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartKit/SessionRegistry.swift @@ -0,0 +1,68 @@ +// +// SessionRegistry.swift +// Encounter +// +// In-memory registry of live EncounterSessions, keyed by EncounterDefinition.ID. +// Injected into the SwiftUI environment at app launch alongside Compendium +// and EncounterStore. +// +// Sessions are retained for the lifetime of the app process. +// Cross-launch persistence is a future enhancement. +// + +import DaggerheartModels +import Foundation +import Observation + +/// In-memory registry of live ``EncounterSession`` objects, keyed by definition ID. +/// +/// Inject once at app launch: +/// ```swift +/// @State private var sessionRegistry = SessionRegistry() +/// +/// ContentView() +/// .environment(sessionRegistry) +/// ``` +/// +/// Retrieve or create a session via ``session(for:definition:compendium:)``. +/// The same session is returned on every call for a given definition ID until +/// ``clearSession(for:)`` is called. +@MainActor +@Observable +public final class SessionRegistry { + public private(set) var sessions: [UUID: EncounterSession] = [:] + + public init() {} + + /// Return the existing session for `definitionID`, or create and store a new one. + public func session( + for definitionID: UUID, + definition: EncounterDefinition, + compendium: Compendium + ) -> EncounterSession { + if let existing = sessions[definitionID] { return existing } + let newSession = EncounterSession.start(from: definition, using: compendium) + sessions[definitionID] = newSession + return newSession + } + + /// Remove the stored session so the next call to ``session(for:definition:compendium:)`` + /// starts a fresh session. + public func clearSession(for definitionID: UUID) { + sessions.removeValue(forKey: definitionID) + } + + /// Discard the existing session and immediately create a fresh one from the given definition. + /// + /// Use this when the GM wants to restart an encounter from scratch without navigating away. + @discardableResult + public func resetSession( + for definitionID: UUID, + definition: EncounterDefinition, + compendium: Compendium + ) -> EncounterSession { + let newSession = EncounterSession.start(from: definition, using: compendium) + sessions[definitionID] = newSession + return newSession + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/Adversary.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/Adversary.swift new file mode 100644 index 0000000..c27ee6a --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/Adversary.swift @@ -0,0 +1,394 @@ +// +// Adversary.swift +// Encounter +// +// Static catalog models for Daggerheart adversaries. +// These represent SRD or homebrew definitions — not live encounter state. +// See EncounterSession.swift for runtime tracking types. +// +// JSON schema compatible with: +// - seansbox/daggerheart-srd (.build/json/adversaries.json) +// - ly0va/beastvault (YAML/JSON library format) +// - javalent/fantasy-statblocks (Daggerheart layout) +// See docs/data-schema.md for full field reference and source notes. +// + +import Foundation + +// MARK: - AdversaryType + +/// The role an adversary plays in a conflict. +/// +/// Source: Daggerheart SRD "Using Adversaries" — each type modifies +/// how the adversary is run at the table. +nonisolated public enum AdversaryType: String, Codable, CaseIterable, Sendable { + /// Tough; deliver powerful attacks. Usually have extra HP. + case bruiser = "Bruiser" + /// Groups of identical creatures acting as a single unit. + /// Special HP/attack rules apply; see SRD "Horde" section. + case horde = "Horde" + /// Command and summon other adversaries. High stress capacity. + case leader = "Leader" + /// Easily dispatched but dangerous in numbers. + case minion = "Minion" + /// Fragile up close; deal high damage at range. + case ranged = "Ranged" + /// Maneuver and exploit opportunities to ambush. + case skulk = "Skulk" + /// Present conversation-based challenges. + case social = "Social" + /// Designed for one-on-one or climactic encounters. + case solo = "Solo" + /// Catch-all for adversaries without an explicit type label. + case standard = "Standard" + /// Enhance allies and disrupt opponents. + case support = "Support" + + // SRD JSON encodes horde variants with HP-per-unit notation, + // e.g. "Horde (3/HP)". Normalise all to .horde. + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + if let exact = Self(rawValue: raw) { + self = exact + } else if raw.hasPrefix("Horde") { + self = .horde + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unknown AdversaryType '\(raw)'" + ) + } + } +} + +// MARK: - AttackRange + +/// Distance bands used for attacks and abilities in Daggerheart. +nonisolated public enum AttackRange: String, Codable, CaseIterable, Sendable { + case melee = "Melee" + case veryClose = "Very Close" + case close = "Close" + case far = "Far" + case veryFar = "Very Far" +} + +// MARK: - FeatureType + +/// The three categories of adversary features from the SRD. +/// +/// - **Actions** trigger when the adversary has the spotlight. +/// - **Reactions** trigger regardless of who has the spotlight. +/// - **Passives** are always in effect. +nonisolated public enum FeatureType: String, Codable, CaseIterable, Sendable { + case action = "action" + case reaction = "reaction" + case passive = "passive" + + /// Infers the feature type from the " - Type" suffix in SRD feature names, + /// e.g. "Earth Eruption - Action" → .action. Defaults to .passive. + public static func inferred(from featureName: String) -> FeatureType { + let lower = featureName.lowercased() + if lower.hasSuffix("- action") { return .action } + if lower.hasSuffix("- reaction") { return .reaction } + return .passive + } +} + +// MARK: - AdversaryFeature + +/// A single named feature (action, reaction, or passive) on an adversary or environment. +nonisolated public struct AdversaryFeature: Codable, Identifiable, Sendable, Equatable, Hashable { + // `id` uses name because feature names are unique within a given adversary. + public var id: String { name } + + public let name: String + public let text: String + public let featType: FeatureType + + // Community JSON uses "feat_type"; SRD JSON omits it entirely. + // When absent, type is inferred from the " - Type" suffix in the feature name. + enum CodingKeys: String, CodingKey { + case name + case text + case featType = "feat_type" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + name = try c.decode(String.self, forKey: .name) + text = try c.decode(String.self, forKey: .text) + if let rawType = try c.decodeIfPresent(String.self, forKey: .featType), + let parsed = FeatureType(rawValue: rawType) + { + featType = parsed + } else { + // SRD JSON omits feat_type; infer from the " - Type" name suffix. + featType = FeatureType.inferred(from: name) + } + } + + public init(name: String, text: String, featType: FeatureType) { + self.name = name + self.text = text + self.featType = featType + } +} + +// MARK: - Adversary + +/// A Daggerheart adversary as defined in the SRD or a homebrew compendium. +/// +/// This is a **catalog model** — it represents the static definition of an +/// adversary, not a live instance being tracked in an encounter. +/// See ``AdversarySlot`` in `EncounterSession.swift` for the runtime type. +/// +/// ## JSON Compatibility +/// The `thresholds` field is stored in community JSON as a single +/// `"major/severe"` string (e.g. `"8/15"`). The custom decoder splits this +/// into `thresholdMajor` and `thresholdSevere` integers. Both the combined +/// string key and pre-split `threshold_major` / `threshold_severe` keys +/// are accepted. +nonisolated public struct Adversary: Codable, Identifiable, Sendable, Equatable, Hashable { + + // MARK: Identity + /// URL-safe slug, e.g. `"acid-burrower"`. Used as stable ID for cross-referencing. + public let id: String + public let name: String + /// Content source tag, always lowercased: `"srd"`, `"homebrew"`, a book name, etc. + /// Values from external JSON are normalized to lowercase at decode time. + public let source: String + /// `true` if this adversary comes from a non-SRD source (homebrew or a named book). + public var isHomebrew: Bool { source != "srd" } + + // MARK: Classification + /// Opposes PCs of the matching tier (1–4). + public let tier: Int + public let type: AdversaryType + + // MARK: Description + public let flavorText: String + /// GM-facing guidance on how to play this adversary at the table. + public let motivesAndTactics: String? + + // MARK: Core Stats + /// The DC for all player rolls made against this adversary. + /// Adversaries never roll Evasion — they use a flat Difficulty. + public let difficulty: Int + /// Damage required to trigger a **Major** hit on this adversary. + public let thresholdMajor: Int + /// Damage required to trigger a **Severe** hit on this adversary. + public let thresholdSevere: Int + public let hp: Int + public let stress: Int + + // MARK: Standard Attack + /// Attack modifier string, e.g. `"+3"`. + public let attackModifier: String + /// Name of the standard attack or weapon, e.g. `"Claws"`. + public let attackName: String + public let attackRange: AttackRange + /// Damage expression, e.g. `"1d12+2 phy"`. Parse with a dice library as needed. + public let damage: String + + // MARK: Additional + /// Optional experience tag, e.g. `"Tremor Sense +2"`. + public let experience: String? + /// Actions, reactions, and passives for this adversary. + public let features: [AdversaryFeature] + + // MARK: - CodingKeys + + enum CodingKeys: String, CodingKey { + case id, name, source, tier, type, description + case motivesAndTactics = "motives_and_tactics" + case difficulty + // Raw combined key from community JSON ("8/15"): + case thresholds + // Pre-split alternative keys (our own export format): + case thresholdMajor = "threshold_major" + case thresholdSevere = "threshold_severe" + case hp, stress + case attackModifier = "atk" + case attackName = "attack" + case attackRange = "range" + case damage, experience + case features = "feature" + } + + // MARK: - Decode Helpers + + /// Derives a URL-safe slug from a display name, e.g. "Acid Burrower" → "acid-burrower". + private static func slug(_ name: String) -> String { + name.lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: "-") + } + + /// Decodes a keyed value that the source JSON may encode as either an Int or a numeric String. + private static func intOrString( + _ container: KeyedDecodingContainer, forKey key: K + ) throws -> Int { + if let intVal = try? container.decode(Int.self, forKey: key) { return intVal } + let str = try container.decode(String.self, forKey: key) + guard let intVal = Int(str) else { + throw DecodingError.dataCorruptedError( + forKey: key, in: container, + debugDescription: "Expected Int or numeric String, got '\(str)'" + ) + } + return intVal + } + + // MARK: - Decodable + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + + // name decoded first so it can serve as the id fallback. + name = try c.decode(String.self, forKey: .name) + let rawID = try c.decodeIfPresent(String.self, forKey: .id) ?? Self.slug(name) + guard !rawID.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: .id, in: c, + debugDescription: "Adversary 'id' must not be empty (name: '\(name)')" + ) + } + id = rawID + // Normalize source to lowercase so "SRD", "srd", "Homebrew", etc. all compare equal. + source = (try c.decodeIfPresent(String.self, forKey: .source) ?? "srd").lowercased() + // SRD JSON encodes numeric stats as strings; homebrew may use ints. + tier = try Self.intOrString(c, forKey: .tier) + type = try c.decode(AdversaryType.self, forKey: .type) + flavorText = try c.decode(String.self, forKey: .description) + motivesAndTactics = try c.decodeIfPresent(String.self, forKey: .motivesAndTactics) + difficulty = try Self.intOrString(c, forKey: .difficulty) + hp = try Self.intOrString(c, forKey: .hp) + stress = try Self.intOrString(c, forKey: .stress) + attackModifier = try c.decode(String.self, forKey: .attackModifier) + attackName = try c.decode(String.self, forKey: .attackName) + attackRange = try c.decode(AttackRange.self, forKey: .attackRange) + damage = try c.decode(String.self, forKey: .damage) + experience = try c.decodeIfPresent(String.self, forKey: .experience) + features = try c.decodeIfPresent([AdversaryFeature].self, forKey: .features) ?? [] + + // Threshold decoding: prefer pre-split keys, fall back to "major/severe" string. + if let major = try c.decodeIfPresent(Int.self, forKey: .thresholdMajor), + let severe = try c.decodeIfPresent(Int.self, forKey: .thresholdSevere) + { + thresholdMajor = major + thresholdSevere = severe + } else if let raw = try c.decodeIfPresent(String.self, forKey: .thresholds) { + // SRD minions encode "None" — they have no damage thresholds. + // Some adversaries encode a partial value like "4/None" where the + // severe threshold is absent. Treat any "None" component as 0. + if raw == "None" { + thresholdMajor = 0 + thresholdSevere = 0 + } else { + let parts = + raw + .split(separator: "/") + .map { $0.trimmingCharacters(in: .whitespaces) } + guard parts.count == 2 else { + throw DecodingError.dataCorruptedError( + forKey: .thresholds, in: c, + debugDescription: "Expected 'major/severe' format, got '\(raw)'" + ) + } + thresholdMajor = Int(parts[0]) ?? 0 + thresholdSevere = Int(parts[1]) ?? 0 + } + } else { + throw DecodingError.keyNotFound( + CodingKeys.thresholds, + DecodingError.Context( + codingPath: c.codingPath, + debugDescription: + "No threshold data found (tried 'thresholds', 'threshold_major'/'threshold_severe')" + ) + ) + } + } + + // MARK: - Encodable (uses pre-split keys for clarity) + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(name, forKey: .name) + try c.encode(source, forKey: .source) + try c.encode(tier, forKey: .tier) + try c.encode(type, forKey: .type) + try c.encode(flavorText, forKey: .description) + try c.encodeIfPresent(motivesAndTactics, forKey: .motivesAndTactics) + try c.encode(difficulty, forKey: .difficulty) + try c.encode(thresholdMajor, forKey: .thresholdMajor) + try c.encode(thresholdSevere, forKey: .thresholdSevere) + try c.encode(hp, forKey: .hp) + try c.encode(stress, forKey: .stress) + try c.encode(attackModifier, forKey: .attackModifier) + try c.encode(attackName, forKey: .attackName) + try c.encode(attackRange, forKey: .attackRange) + try c.encode(damage, forKey: .damage) + try c.encodeIfPresent(experience, forKey: .experience) + try c.encode(features, forKey: .features) + } + + // MARK: - Memberwise init (for previews / tests) + + public init( + id: String, + name: String, + source: String = "srd", + tier: Int, + type: AdversaryType, + flavorText: String, + motivesAndTactics: String? = nil, + difficulty: Int, + thresholdMajor: Int, + thresholdSevere: Int, + hp: Int, + stress: Int, + attackModifier: String, + attackName: String, + attackRange: AttackRange, + damage: String, + experience: String? = nil, + features: [AdversaryFeature] = [] + ) { + self.id = id + self.name = name + self.source = source + self.tier = tier + self.type = type + self.flavorText = flavorText + self.motivesAndTactics = motivesAndTactics + self.difficulty = difficulty + self.thresholdMajor = thresholdMajor + self.thresholdSevere = thresholdSevere + self.hp = hp + self.stress = stress + self.attackModifier = attackModifier + self.attackName = attackName + self.attackRange = attackRange + self.damage = damage + self.experience = experience + self.features = features + } +} + +// MARK: - CustomStringConvertible + +extension Adversary: CustomStringConvertible { + /// A human-readable summary: name, tier, and type. + public var description: String { "\(name) (Tier \(tier) \(type))" } +} + +extension Adversary: CustomDebugStringConvertible { + /// A debug-focused identity string with key combat stats. + public var debugDescription: String { + "Adversary(id: \(id), tier: \(tier), type: \(type), hp: \(hp), stress: \(stress), difficulty: \(difficulty))" + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/Condition.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/Condition.swift new file mode 100644 index 0000000..28a3f5d --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/Condition.swift @@ -0,0 +1,88 @@ +// +// Condition.swift +// Encounter +// +// Status effects that can be applied to combatants in a Daggerheart encounter. +// +// The three standard conditions come from the SRD: +// - Hidden: Rolls against this creature have disadvantage. +// - Restrained: Cannot move, but can still take actions. +// - Vulnerable: All rolls targeting this creature have advantage. +// +// Features may impose unique conditions via the `.custom` case. +// Per the SRD, the same condition cannot stack on a target. +// + +import Foundation + +/// A condition that can be applied to a combatant in a Daggerheart encounter. +/// +/// The SRD defines three standard conditions. Feature abilities may impose +/// additional conditions via `.custom(String)`. The same condition cannot +/// stack on a single target — use `Set` to enforce this. +nonisolated public enum Condition: Hashable, Sendable, Codable { + /// Rolls against this creature have disadvantage. + /// Removed when an adversary moves to see you, you move into sight, or you attack. + case hidden + /// Cannot move, but can take actions from current position. + /// Cleared with a successful action roll (PCs) or GM spending spotlight (adversaries). + case restrained + /// All rolls targeting this creature have advantage. + /// Applied when a PC marks all Stress. Cleared with a move. + case vulnerable + /// A feature-imposed condition with a custom name. + case custom(String) + + /// The three standard SRD conditions available for toggle in encounter UI. + /// `.custom` is omitted because it requires a name parameter. + public static let standardConditions: [Condition] = [.hidden, .restrained, .vulnerable] + + /// Human-readable display name for UI rendering. + public var displayName: String { + switch self { + case .hidden: return "Hidden" + case .restrained: return "Restrained" + case .vulnerable: return "Vulnerable" + case .custom(let name): return name + } + } + + // MARK: - Codable + + private enum CodingKeys: String, CodingKey { + case type, name + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .hidden: + try container.encode("hidden", forKey: .type) + case .restrained: + try container.encode("restrained", forKey: .type) + case .vulnerable: + try container.encode("vulnerable", forKey: .type) + case .custom(let name): + try container.encode("custom", forKey: .type) + try container.encode(name, forKey: .name) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "hidden": self = .hidden + case "restrained": self = .restrained + case "vulnerable": self = .vulnerable + case "custom": + let name = try container.decode(String.self, forKey: .name) + self = .custom(name) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, in: container, + debugDescription: "Unknown condition type: '\(type)'" + ) + } + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/ContentFingerprint.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/ContentFingerprint.swift new file mode 100644 index 0000000..b359e76 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/ContentFingerprint.swift @@ -0,0 +1,29 @@ +// +// ContentFingerprint.swift +// Encounter +// +// Combined content fingerprint for staleness detection. +// Stored on ContentSource after each successful fetch. +// + +import Foundation + +/// A combined fingerprint for a downloaded content pack. +/// +/// `sha256` validates local file integrity and detects actual content changes. +/// `etag` enables conditional HTTP GET (`If-None-Match`) to skip re-downloading +/// unchanged content, saving bandwidth. +/// +/// Both fields are computed from the HTTP response at fetch time and persisted +/// as part of ``ContentSource``. +nonisolated public struct ContentFingerprint: Codable, Equatable, Hashable, Sendable { + /// SHA-256 hex digest of the downloaded bytes. + public let sha256: String + /// HTTP ETag from the last successful response, if the server provided one. + public let etag: String? + + public init(sha256: String, etag: String? = nil) { + self.sha256 = sha256 + self.etag = etag + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/ContentSource.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/ContentSource.swift new file mode 100644 index 0000000..da9d226 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/ContentSource.swift @@ -0,0 +1,144 @@ +// +// ContentSource.swift +// Encounter +// +// A registered community content source (a URL pointing to a .dhpack file, +// or a locally imported .dhpack file with no remote URL). +// Persisted to Application Support so the source list survives app restarts. +// + +import Foundation + +/// A registered content source for community adversary and environment packs. +/// +/// Sources come in two flavours: +/// - **Remote** — a version-pinned URL (see ADR-0017). The user adds a URL; +/// `ContentStore` fetches and caches the pack. Refresh is user-initiated with +/// exponential backoff on failures (see ADR-0027). +/// - **Local import** — a `.dhpack` file opened from Files, AirDrop, or Mail. +/// `url` is `nil`. The pack content is written to disk and reloaded on every +/// launch exactly like a remote source. Removal requires explicit user action +/// (tracked in a separate issue; `ContentStore.removeSource(id:)` is the API). +/// +/// ## Persistence +/// The array of registered `ContentSource` values is stored in +/// `Application Support/gwillish.Encounter/sources/index.json`. +/// The pack content itself lives in +/// `Application Support/gwillish.Encounter/sources//`. +/// +/// ## Date fields +/// All `Date` properties are stored as ISO8601 Zulu strings per ADR-0013. +nonisolated public struct ContentSource: Codable, Identifiable, Equatable, Hashable, Sendable { + + /// Stable, lowercase slug identifying this source, e.g. `"expanded-adversary-compendium"`. + /// Used as a directory name and as the key in ``Compendium``'s sources tier. + public let id: String + + /// Display name shown in the source management UI. + public var name: String + + /// Remote URL of the `.dhpack` file, or `nil` for locally imported packs. + /// + /// When `nil`, `ContentStore.fetchSource(id:)` is a no-op for this source. + /// Must be version-pinned when set (see ADR-0017). + public var url: URL? + + /// ISO8601 Zulu date of the last successful fetch or import. + /// Nil if this source has never been successfully loaded. + /// Always stored and compared in UTC per ADR-0013. + public var lastFetched: Date? + + /// Content fingerprint from the last successful fetch. + /// Nil for locally imported packs (no HTTP response to fingerprint). + public var fingerprint: ContentFingerprint? + + /// Number of consecutive fetch failures since the last successful fetch. + /// Always 0 for local imports (they are never re-fetched automatically). + public private(set) var consecutiveFailures: Int + + /// The earliest date at which the next fetch attempt is permitted. + /// Nil means fetching is allowed immediately, or the source is a local import. + /// Set by exponential backoff whenever a remote fetch fails. + public private(set) var nextAllowedFetch: Date? + + // MARK: - Init + + /// Create a remote source (with a URL to fetch from). + public init( + id: String, + name: String, + url: URL, + lastFetched: Date? = nil, + fingerprint: ContentFingerprint? = nil, + consecutiveFailures: Int = 0, + nextAllowedFetch: Date? = nil + ) { + self.id = id + self.name = name + self.url = url + self.lastFetched = lastFetched + self.fingerprint = fingerprint + self.consecutiveFailures = consecutiveFailures + self.nextAllowedFetch = nextAllowedFetch + } + + /// Create a local import source (no remote URL). + public init(id: String, name: String, importedAt date: Date = .now) { + self.id = id + self.name = name + self.url = nil + self.lastFetched = date + self.fingerprint = nil + self.consecutiveFailures = 0 + self.nextAllowedFetch = nil + } + + // MARK: - Convenience + + /// `true` if this source was locally imported rather than fetched from a URL. + public var isLocalImport: Bool { url == nil } + + // MARK: - Throttle check + + /// Returns `true` if backoff is currently active for this source. + /// Always `false` for local imports. + public func isThrottled(at date: Date = .now) -> Bool { + guard let next = nextAllowedFetch else { return false } + return date < next + } + + // MARK: - Backoff mutations + // + // These return new values rather than mutating in place, keeping ContentSource + // a pure value type and making the state transitions easy to test. + + /// Returns a copy with exponential backoff applied for one additional failure. + /// + /// Delay formula: `min(1h × 2^consecutiveFailures, 7 days)` + /// - 1st failure → 1 hour + /// - 2nd failure → 2 hours + /// - 3rd failure → 4 hours + /// - … + /// - 10th failure → capped at 7 days + public func recordingFailure(at date: Date = .now) -> ContentSource { + var updated = self + let exponent = Double(consecutiveFailures) // starts at 0 on first failure + let hours = pow(2.0, exponent) // 1, 2, 4, 8, … + let delay = min(hours * 3_600, 7 * 24 * 3_600) // cap at 7 days + updated.consecutiveFailures += 1 + updated.nextAllowedFetch = date.addingTimeInterval(delay) + return updated + } + + /// Returns a copy recording a successful remote fetch, resetting all backoff state. + public func recordingSuccess(fingerprint: ContentFingerprint, at date: Date = .now) + -> ContentSource + { + var updated = self + updated.lastFetched = date + updated.fingerprint = fingerprint + updated.consecutiveFailures = 0 + updated.nextAllowedFetch = nil + return updated + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/ContentStoreError.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/ContentStoreError.swift new file mode 100644 index 0000000..ccf6298 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/ContentStoreError.swift @@ -0,0 +1,41 @@ +// +// ContentStoreError.swift +// Encounter +// +// Typed errors for ContentStore, ContentFetcher, and ContentWriter. +// + +import Foundation + +/// Errors produced by the content loading and update pipeline. +nonisolated public enum ContentStoreError: Error, LocalizedError, Sendable { + /// The source is currently throttled by exponential backoff. + case fetchThrottled(sourceID: String, until: Date) + /// A network or URLSession error occurred during fetch. + case networkError(sourceID: String, underlying: Error) + /// The downloaded data could not be decoded as valid content. + case decodingFailed(sourceID: String, underlying: Error) + /// The atomic file write failed. + case writeFailed(sourceID: String, underlying: Error) + /// Reading a content file from disk failed. + case readFailed(sourceID: String, underlying: Error) + /// The content is structurally invalid (e.g. empty adversary ID). + case invalidContent(sourceID: String, reason: String) + + public var errorDescription: String? { + switch self { + case .fetchThrottled(let id, let until): + return "Source '\(id)' is throttled until \(until.formatted(.dateTime))." + case .networkError(let id, let error): + return "Network error for '\(id)': \(error.localizedDescription)" + case .decodingFailed(let id, let error): + return "Decode failed for '\(id)': \(error.localizedDescription)" + case .writeFailed(let id, let error): + return "Write failed for '\(id)': \(error.localizedDescription)" + case .readFailed(let id, let error): + return "Read failed for '\(id)': \(error.localizedDescription)" + case .invalidContent(let id, let reason): + return "Invalid content from '\(id)': \(reason)" + } + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/DHPackContent.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/DHPackContent.swift new file mode 100644 index 0000000..aad798f --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/DHPackContent.swift @@ -0,0 +1,77 @@ +// +// DHPackContent.swift +// Encounter +// +// Decoded representation of a .dhpack file. +// A .dhpack is a JSON object with optional adversaries and environments arrays, +// compatible with the seansbox/daggerheart-srd format (see ADR-0024). +// + +import Foundation + +#if canImport(UniformTypeIdentifiers) + import UniformTypeIdentifiers + + // MARK: - UTType + + extension UTType { + /// The `.dhpack` file type: a JSON content pack for Encounter. + /// + /// Declared in `Info.plist` as `gwillish.Encounter.dhpack`, conforming to + /// `public.json → public.text → public.data`. The `public.json` conformance + /// allows text editors and Quick Look to preview the file, and lets the OS + /// route AirDrop and Files-app opens to Encounter (see ADR-0016). + /// + /// > Note: The UTI is *declared* here as a Swift constant; the OS-level + /// > registration that enables file-open routing lives in the app's `Info.plist` + /// > and cannot be moved into a Swift Package. + /// + /// Use `static let` (not `var`) because the type is exported by this app's + /// bundle and is stable for the app's lifetime. + public static let dhpack: UTType = UTType( + exportedAs: "gwillish.Encounter.dhpack", + conformingTo: .json) + } +#endif + +/// The decoded contents of a `.dhpack` file. +/// +/// A pack may contain adversaries, environments, or both. Absent arrays decode +/// as empty rather than throwing, so partial packs are accepted. +/// +/// ## Format +/// ```json +/// { +/// "adversaries": [ … ], +/// "environments": [ … ] +/// } +/// ``` +/// A root-level array of adversaries (bare seansbox export format) is also +/// accepted and treated as a pack with environments omitted. +nonisolated public struct DHPackContent: Sendable { + public let adversaries: [Adversary] + public let environments: [DaggerheartEnvironment] + + public init(adversaries: [Adversary], environments: [DaggerheartEnvironment]) { + self.adversaries = adversaries + self.environments = environments + } +} + +nonisolated extension DHPackContent: Decodable { + private enum CodingKeys: String, CodingKey { + case adversaries, environments + } + + public init(from decoder: Decoder) throws { + // Try the keyed object format first {"adversaries":[…], "environments":[…]}. + // Fall back to a bare adversary array (direct seansbox export). + if let keyed = try? decoder.container(keyedBy: CodingKeys.self) { + adversaries = (try? keyed.decode([Adversary].self, forKey: .adversaries)) ?? [] + environments = (try? keyed.decode([DaggerheartEnvironment].self, forKey: .environments)) ?? [] + } else { + adversaries = try decoder.singleValueContainer().decode([Adversary].self) + environments = [] + } + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/DaggerheartEnvironment.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/DaggerheartEnvironment.swift new file mode 100644 index 0000000..b0cbdc1 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/DaggerheartEnvironment.swift @@ -0,0 +1,112 @@ +// +// DaggerheartEnvironment.swift +// Encounter +// +// Daggerheart environment catalog model. +// Environments are distinct from adversaries: they have no HP or Stress, +// and represent terrain hazards, magical phenomena, or scene elements +// that interact with the action economy. +// +// Named "DaggerheartEnvironment" to avoid collision with SwiftUI's +// `Environment` property wrapper. +// +// JSON schema: same community ecosystem as Adversary.swift. +// BeastVault discriminates adversary vs. environment by absence of hp/stress. +// See docs/data-schema.md for the full field reference. +// + +import Foundation + +/// A Daggerheart environment — a scene element with features but no HP or Stress. +/// +/// Environments share the same feature schema as adversaries but represent +/// location hazards, terrain, or interactive elements rather than combatants. +/// They participate in encounters but are not tracked as HP pools. +nonisolated public struct DaggerheartEnvironment: Codable, Identifiable, Sendable, Equatable, + Hashable +{ + + // MARK: Identity + /// URL-safe slug, e.g. `"collapsing-cavern"`. + public let id: String + public let name: String + /// Content source tag, always lowercased: `"srd"`, `"homebrew"`, a book name, etc. + public let source: String + /// `true` if this environment comes from a non-SRD source (homebrew or a named book). + public var isHomebrew: Bool { source != "srd" } + + // MARK: Description + public let flavorText: String + + // MARK: Features + /// Passives, reactions, and actions this environment contributes to the scene. + public let features: [AdversaryFeature] + + // MARK: - CodingKeys + + enum CodingKeys: String, CodingKey { + case id, name, source, description + // SRD JSON uses "feature"; homebrew/export uses "feature" too. + case features = "feature" + } + + // MARK: - Decodable + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + // name decoded first so it can serve as the id fallback. + name = try c.decode(String.self, forKey: .name) + // SRD JSON has no id field; derive a slug from the name. + id = + try c.decodeIfPresent(String.self, forKey: .id) + ?? name.lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: "-") + // Normalize source to lowercase so "SRD", "srd", "Homebrew", etc. all compare equal. + source = (try c.decodeIfPresent(String.self, forKey: .source) ?? "srd").lowercased() + flavorText = try c.decode(String.self, forKey: .description) + features = try c.decodeIfPresent([AdversaryFeature].self, forKey: .features) ?? [] + } + + // MARK: - Encodable + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(name, forKey: .name) + try c.encode(source, forKey: .source) + try c.encode(flavorText, forKey: .description) + try c.encode(features, forKey: .features) + } + + // MARK: - Memberwise init (for previews / tests) + + public init( + id: String, + name: String, + source: String = "srd", + flavorText: String, + features: [AdversaryFeature] = [] + ) { + self.id = id + self.name = name + self.source = source + self.flavorText = flavorText + self.features = features + } +} + +// MARK: - CustomStringConvertible + +extension DaggerheartEnvironment: CustomStringConvertible { + /// The environment's display name. + public var description: String { name } +} + +extension DaggerheartEnvironment: CustomDebugStringConvertible { + /// A debug-focused identity string with feature count. + public var debugDescription: String { + "Environment(id: \(id), name: \(name), features: \(features.count))" + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/DaggerheartModels.docc/DaggerheartModels.md b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/DaggerheartModels.docc/DaggerheartModels.md new file mode 100644 index 0000000..4a4af4e --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/DaggerheartModels.docc/DaggerheartModels.md @@ -0,0 +1,42 @@ +# ``DaggerheartModels`` + +Foundation-only value types for the Daggerheart tabletop RPG system. + +## Overview + +`DaggerheartModels` provides the core data types for representing Daggerheart +game content. All types are pure Swift value types (`struct` / `enum`) with no +Apple-platform dependencies, making them suitable for use on Linux and in +server-side or CLI tools. + +These types are the shared vocabulary between the Encounter app, the +`validate-dhpack` CLI tool, and any other tooling built around the Daggerheart +ecosystem. + +## Topics + +### Adversaries + +- ``Adversary`` +- ``AdversaryType`` +- ``AdversaryFeature`` +- ``FeatureType`` +- ``AttackRange`` + +### Environments + +- ``DaggerheartEnvironment`` + +### Encounters + +- ``EncounterDefinition`` +- ``PlayerSlot`` +- ``DifficultyBudget`` +- ``Condition`` + +### Content Packs + +- ``DHPackContent`` +- ``ContentSource`` +- ``ContentFingerprint`` +- ``ContentStoreError`` diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/DifficultyBudget.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/DifficultyBudget.swift new file mode 100644 index 0000000..16b3ede --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/DifficultyBudget.swift @@ -0,0 +1,164 @@ +// +// DifficultyBudget.swift +// Encounter +// +// Pure-function service for computing Daggerheart encounter difficulty +// using the Battle Points system from the SRD. +// +// All functions are static and take only value-type inputs. +// This type has no state and no dependencies on session or UI. +// +// SRD Reference — "Building Balanced Encounters": +// Base budget: (3 × playerCount) + 2 +// Then apply adjustments and spend points to add adversaries by type. +// + +import Foundation + +/// Pure-function namespace for computing Daggerheart encounter difficulty +/// using the Battle Points system from the SRD. +/// +/// All functions are static and operate on value types only. +/// No state, no dependencies on ``EncounterSession`` or UI. +nonisolated public enum DifficultyBudget { + + // MARK: - Battle Point Cost per Type + + /// The Battle Point cost for a single adversary of the given type. + /// + /// Per SRD "Building Balanced Encounters": + /// - Minion group, Social, Support: 1 point + /// - Horde, Ranged, Skulk, Standard: 2 points + /// - Leader: 3 points + /// - Bruiser: 4 points + /// - Solo: 5 points + public static func cost(for type: AdversaryType) -> Int { + switch type { + case .minion, .social, .support: return 1 + case .horde, .ranged, .skulk, .standard: return 2 + case .leader: return 3 + case .bruiser: return 4 + case .solo: return 5 + } + } + + // MARK: - Base Budget + + /// The starting Battle Point budget before adjustments. + /// + /// Formula: `(3 × playerCount) + 2` + public static func baseBudget(playerCount: Int) -> Int { + (3 * playerCount) + 2 + } + + // MARK: - Total Cost + + /// Sum of Battle Point costs for a list of adversary types. + public static func totalCost(for types: [AdversaryType]) -> Int { + types.reduce(0) { $0 + cost(for: $1) } + } + + // MARK: - Rating + + /// A snapshot of the encounter's difficulty budget analysis. + nonisolated public struct Rating: Sendable, Equatable, Hashable { + /// Total Battle Points available (base budget + adjustment). + public let budget: Int + /// Total Battle Points spent on the adversary roster. + public let totalCost: Int + /// Budget minus cost. Negative means over-budget. + public let remaining: Int + + public init(budget: Int, totalCost: Int, remaining: Int) { + self.budget = budget + self.totalCost = totalCost + self.remaining = remaining + } + } + + /// Compute the difficulty rating for an adversary roster against a player count. + /// + /// - Parameters: + /// - adversaryTypes: The types of all adversaries in the encounter. + /// - playerCount: Number of player characters. + /// - budgetAdjustment: Manual adjustment to the base budget (from ``Adjustment`` point values). + /// - Returns: A ``Rating`` with budget, cost, and remaining points. + public static func rating( + adversaryTypes: [AdversaryType], + playerCount: Int, + budgetAdjustment: Int = 0 + ) -> Rating { + let budget = baseBudget(playerCount: playerCount) + budgetAdjustment + let cost = totalCost(for: adversaryTypes) + return Rating(budget: budget, totalCost: cost, remaining: budget - cost) + } + + // MARK: - Adjustment Suggestions + + /// Predefined budget adjustments from the SRD. + nonisolated public enum Adjustment: Sendable, Equatable, Hashable, CaseIterable { + /// -1 for an easier or shorter fight. + case easierFight + /// -2 if using 2+ Solo adversaries. + case multipleSolos + /// -2 if adding +1d4 (or static +2) to all adversary damage. + case boostedDamage + /// +1 if choosing an adversary from a lower tier. + case lowerTierAdversary + /// +1 if no Bruisers, Hordes, Leaders, or Solos in the roster. + case noBigThreats + /// +2 for a harder or longer fight. + case harderFight + + /// The Battle Point adjustment this represents. + public var pointValue: Int { + switch self { + case .easierFight: return -1 + case .multipleSolos: return -2 + case .boostedDamage: return -2 + case .lowerTierAdversary: return 1 + case .noBigThreats: return 1 + case .harderFight: return 2 + } + } + + /// Human-readable description for UI display. + public var label: String { + switch self { + case .easierFight: return "Easier/shorter fight" + case .multipleSolos: return "Using 2+ Solo adversaries" + case .boostedDamage: return "Boosted adversary damage (+1d4 or +2)" + case .lowerTierAdversary: return "Adversary from a lower tier" + case .noBigThreats: return "No Bruisers, Hordes, Leaders, or Solos" + case .harderFight: return "Harder/longer fight" + } + } + } + + /// Determine which SRD adjustments apply automatically based on the roster. + /// + /// Only returns adjustments that can be mechanically detected: + /// - `.multipleSolos` if 2+ Solo types + /// - `.noBigThreats` if no Bruiser, Horde, Leader, or Solo types + /// + /// GM-discretionary adjustments (easier/harder fight, boosted damage, + /// lower tier) must be toggled manually in the UI. + public static func suggestedAdjustments( + adversaryTypes: [AdversaryType] + ) -> Set { + var result: Set = [] + + let soloCount = adversaryTypes.count(where: { $0 == .solo }) + if soloCount >= 2 { + result.insert(.multipleSolos) + } + + let bigThreatTypes: Set = [.bruiser, .horde, .leader, .solo] + let hasBigThreat = adversaryTypes.contains { bigThreatTypes.contains($0) } + if !hasBigThreat && !adversaryTypes.isEmpty { + result.insert(.noBigThreats) + } + + return result + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/EncounterDefinition.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/EncounterDefinition.swift new file mode 100644 index 0000000..64cc3af --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/EncounterDefinition.swift @@ -0,0 +1,113 @@ +// +// EncounterDefinition.swift +// Encounter +// +// A saveable, shareable encounter template. +// Captures the GM's prep work: which adversaries and environments to include, +// which players are at the table, and any notes. +// +// To run an encounter, create an EncounterSession from a definition +// using EncounterSession.start(from:using:). +// +// Catalog vs. Runtime Split: +// - Definition stores adversary/environment IDs (references into the Compendium). +// - Session resolves those IDs into live AdversarySlot and EnvironmentSlot +// instances with mutable HP, Stress, and condition tracking. +// + +import Foundation + +// MARK: - PlayerConfig + +/// Configuration for a single player character in an encounter definition. +/// +/// This is the `Codable`, value-type counterpart of ``PlayerSlot``. +/// When an ``EncounterSession`` is started from a definition, each +/// `PlayerConfig` becomes a ``PlayerSlot`` with fresh runtime state. +nonisolated public struct PlayerConfig: Codable, Sendable, Equatable, Hashable, Identifiable { + public let id: UUID + public let name: String + public let maxHP: Int + public let maxStress: Int + public let evasion: Int + public let thresholdMajor: Int + public let thresholdSevere: Int + public let armorSlots: Int + + public init( + id: UUID = UUID(), + name: String, + maxHP: Int, + maxStress: Int, + evasion: Int, + thresholdMajor: Int, + thresholdSevere: Int, + armorSlots: Int + ) { + self.id = id + self.name = name + self.maxHP = maxHP + self.maxStress = maxStress + self.evasion = evasion + self.thresholdMajor = thresholdMajor + self.thresholdSevere = thresholdSevere + self.armorSlots = armorSlots + } +} + +// MARK: - EncounterDefinition + +/// A saveable, shareable encounter template. +/// +/// `EncounterDefinition` captures the GM's prep work: which adversaries +/// and environments to include, which players are at the table, and any +/// notes. It is a pure value type with full `Codable` conformance, +/// suitable for persistence to disk, CloudKit, or JSON export. +/// +/// To run an encounter, create an ``EncounterSession`` from a definition +/// using ``EncounterSession/start(from:using:)``. +nonisolated public struct EncounterDefinition: Codable, Sendable, Equatable, Hashable, Identifiable +{ + public let id: UUID + public var name: String + + /// Adversary catalog IDs. Duplicates represent multiple copies of the same adversary. + public var adversaryIDs: [String] + + /// Environment catalog IDs. + public var environmentIDs: [String] + + /// Player character configurations for this encounter. + public var playerConfigs: [PlayerConfig] + + /// Freeform GM notes for encounter prep. + public var gmNotes: String + + // MARK: Timestamps + + /// When this definition was first created. + public let createdAt: Date + + /// Stamped by ``EncounterStore/save(_:)`` — do not set directly. + public var modifiedAt: Date + + public init( + id: UUID = UUID(), + name: String, + adversaryIDs: [String] = [], + environmentIDs: [String] = [], + playerConfigs: [PlayerConfig] = [], + gmNotes: String = "", + createdAt: Date = .now, + modifiedAt: Date = .now + ) { + self.id = id + self.name = name + self.adversaryIDs = adversaryIDs + self.environmentIDs = environmentIDs + self.playerConfigs = playerConfigs + self.gmNotes = gmNotes + self.createdAt = createdAt + self.modifiedAt = modifiedAt + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/EncounterParticipant.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/EncounterParticipant.swift new file mode 100644 index 0000000..61ec276 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/EncounterParticipant.swift @@ -0,0 +1,28 @@ +// +// EncounterParticipant.swift +// DaggerheartModels +// +// Protocols for encounter participants, enabling the spotlight and +// combat mutation APIs to work uniformly across adversary and player slots. +// + +import Foundation + +/// A participant in a Daggerheart encounter that can hold the spotlight. +/// +/// All adversary, environment, and player slots conform to this protocol, +/// allowing the spotlight API to accept any participant type uniformly. +public protocol EncounterParticipant: Identifiable where ID == UUID {} + +/// An encounter participant that tracks HP, Stress, and Conditions. +/// +/// Conformed to by ``AdversarySlot`` and ``PlayerSlot``. Enables +/// unified combat mutation methods on ``EncounterSession`` without +/// requiring separate adversary- and player-specific overloads. +public protocol CombatParticipant: EncounterParticipant { + var currentHP: Int { get set } + var maxHP: Int { get } + var currentStress: Int { get set } + var maxStress: Int { get } + var conditions: Set { get set } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/PlayerSlot.swift b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/PlayerSlot.swift new file mode 100644 index 0000000..4657e31 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/DaggerheartModels/PlayerSlot.swift @@ -0,0 +1,80 @@ +// +// PlayerSlot.swift +// Encounter +// +// A player character participant in a live encounter. +// Tracks the combat-relevant subset of a PC's stats that the GM needs +// during an encounter. This is intentionally not a full character sheet; +// the primary source of truth for a PC's stats remains with the player. +// +// Daggerheart Stats Reference: +// - Evasion: The DC for rolls made against this PC (class-based + mods). +// - Thresholds: Major/Severe damage thresholds (armor base + level + mods). +// - Armor Slots: Marks available to reduce damage severity (equals Armor Score). +// + +import Foundation + +/// A player character participant in a live encounter. +/// +/// Tracks combat-relevant PC stats the GM needs to resolve hits and +/// track health during play. The full character sheet remains with the player. +nonisolated public struct PlayerSlot: CombatParticipant, Sendable, Equatable, Hashable { + public let id: UUID + public var name: String + + // MARK: Hit Points + public let maxHP: Int + public var currentHP: Int + + // MARK: Stress + public let maxStress: Int + public var currentStress: Int + + // MARK: Defense + /// The DC for all rolls made against this PC. + public let evasion: Int + /// Damage at or above this triggers a Major hit (mark 2 HP). + public let thresholdMajor: Int + /// Damage at or above this triggers a Severe hit (mark 3 HP). + public let thresholdSevere: Int + + // MARK: Armor + /// Total Armor Score (number of Armor Slots available). + public let armorSlots: Int + /// Remaining unused Armor Slots. + public var currentArmorSlots: Int + + // MARK: Conditions + public var conditions: Set + + // MARK: - Init + + public init( + id: UUID = UUID(), + name: String, + maxHP: Int, + currentHP: Int? = nil, + maxStress: Int, + currentStress: Int = 0, + evasion: Int, + thresholdMajor: Int, + thresholdSevere: Int, + armorSlots: Int, + currentArmorSlots: Int? = nil, + conditions: Set = [] + ) { + self.id = id + self.name = name + self.maxHP = maxHP + self.currentHP = currentHP ?? maxHP + self.maxStress = maxStress + self.currentStress = currentStress + self.evasion = evasion + self.thresholdMajor = thresholdMajor + self.thresholdSevere = thresholdSevere + self.armorSlots = armorSlots + self.currentArmorSlots = currentArmorSlots ?? armorSlots + self.conditions = conditions + } +} diff --git a/.claude/worktrees/agent-a84e8640/Sources/validate-dhpack/main.swift b/.claude/worktrees/agent-a84e8640/Sources/validate-dhpack/main.swift new file mode 100644 index 0000000..0c641b2 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Sources/validate-dhpack/main.swift @@ -0,0 +1,50 @@ +import ArgumentParser +import DaggerheartModels +import Foundation + +// validate-dhpack — validates one or more .dhpack files against the DaggerheartModels schema. +// +// Usage: validate-dhpack [ ...] +// Exit 0: all files are valid JSON and decode without error. +// Exit 1: one or more files failed validation (errors printed to stderr). +// +// Full field-level validation (required fields, value ranges) is tracked in +// https://github.com/gwillish/DaggerheartModels/issues/5 + +struct ValidateDHPack: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "validate-dhpack", + abstract: "Validate one or more .dhpack files against the DaggerheartModels schema." + ) + + @Argument(help: "One or more .dhpack files to validate.") + var files: [String] + + mutating func run() throws { + var failed = false + + for path in files { + let url = URL(filePath: path) + do { + let data = try Data(contentsOf: url) + let pack = try JSONDecoder().decode(DHPackContent.self, from: data) + let adversaryCount = pack.adversaries.count + let environmentCount = pack.environments.count + print( + "\(path): OK (\(adversaryCount) adversar\(adversaryCount == 1 ? "y" : "ies"), " + + "\(environmentCount) environment\(environmentCount == 1 ? "" : "s"))" + ) + } catch { + FileHandle.standardError.write( + Data("\(path): FAILED — \(error.localizedDescription)\n".utf8)) + failed = true + } + } + + if failed { + throw ExitCode.failure + } + } +} + +ValidateDHPack.main() diff --git a/.claude/worktrees/agent-a84e8640/Tests/DaggerheartKitTests/CompendiumTests.swift b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartKitTests/CompendiumTests.swift new file mode 100644 index 0000000..a52498b --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartKitTests/CompendiumTests.swift @@ -0,0 +1,140 @@ +// +// CompendiumTests.swift +// DaggerheartKitTests +// +// Unit tests for Compendium: async load, dedup, homebrew management. +// +// Compendium defaults to Bundle.module (DaggerheartKit's resource bundle), +// which ships the full SRD JSON. Pass Bundle.main to force the error path +// in contexts where the SRD resources are absent. +// + +import DaggerheartModels +import Foundation +import Testing + +@testable import DaggerheartKit + +// MARK: - Compendium async load + +@MainActor struct CompendiumLoadTests { + + @Test func isLoadingFalseAfterLoadCompletes() async { + let compendium = Compendium() + try? await compendium.load() + #expect(compendium.isLoading == false) + } + + @Test func concurrentLoadCallsAreDeduped() async { + let compendium = Compendium() + await withTaskGroup(of: Void.self) { group in + group.addTask { try? await compendium.load() } + group.addTask { try? await compendium.load() } + } + #expect(compendium.isLoading == false) + } + + /// Verifies load() throws and sets loadError when the bundle has no SRD JSON. + /// Bundle.main is used here because it has no DaggerheartKit resources in this + /// test context, which reliably exercises the error path. + @Test func loadSetsLoadErrorOnMissingResource() async { + let compendium = Compendium(bundle: .main) + var didThrow = false + do { + try await compendium.load() + } catch { + didThrow = true + } + if didThrow { + #expect(compendium.loadError != nil) + } + #expect(compendium.isLoading == false) + } + + @Test func homebrewSurvivesFailedLoad() async { + let compendium = Compendium(bundle: .main) + compendium.addAdversary( + Adversary( + id: "test-creature", name: "Test", tier: 1, type: .minion, + flavorText: "desc", difficulty: 8, thresholdMajor: 3, thresholdSevere: 6, + hp: 3, stress: 2, attackModifier: "+1", attackName: "Bite", + attackRange: .veryClose, damage: "1d6 phy" + )) + try? await compendium.load() + #expect(compendium.isLoading == false) + } +} + +// MARK: - Homebrew distinction in Compendium + +@MainActor struct CompendiumHomebrewTests { + + private func makeSoldier(id: String = "ironguard-soldier") -> Adversary { + Adversary( + id: id, name: "Ironguard Soldier", tier: 1, type: .bruiser, + flavorText: "A disciplined mercenary.", difficulty: 11, + thresholdMajor: 5, thresholdSevere: 10, hp: 6, stress: 3, + attackModifier: "+3", attackName: "Longsword", + attackRange: .veryClose, damage: "1d10+3 phy" + ) + } + + private func makeEnv(id: String = "bridge") -> DaggerheartEnvironment { + DaggerheartEnvironment(id: id, name: "Bridge", flavorText: "A rope bridge.") + } + + @Test func homebrewAdversaryAppearsInHomebrewList() { + let compendium = Compendium() + compendium.addAdversary(makeSoldier()) + #expect(compendium.homebrewAdversaries.count == 1) + #expect(compendium.homebrewAdversaries[0].id == "ironguard-soldier") + } + + @Test func homebrewAdversaryAppearsInAllAdversaries() { + let compendium = Compendium() + compendium.addAdversary(makeSoldier()) + #expect(compendium.adversaries.contains { $0.id == "ironguard-soldier" }) + } + + @Test func srdAdversaryDoesNotAppearInHomebrewList() { + let compendium = Compendium() + #expect(compendium.homebrewAdversaries.isEmpty) + } + + @Test func homebrewOverridesSRDEntryOnLookup() { + let compendium = Compendium() + var variant = makeSoldier() + compendium.addAdversary(variant) + variant = Adversary( + id: "ironguard-soldier", name: "Elite Ironguard", tier: 2, type: .bruiser, + flavorText: "Upgraded.", difficulty: 14, thresholdMajor: 7, thresholdSevere: 14, + hp: 10, stress: 4, attackModifier: "+5", attackName: "Longsword", + attackRange: .veryClose, damage: "2d10+5 phy" + ) + compendium.addAdversary(variant) + #expect(compendium.adversary(id: "ironguard-soldier")?.name == "Elite Ironguard") + #expect(compendium.homebrewAdversaries.count == 1) + } + + @Test func removeHomebrewAdversaryRemovesFromBothLists() { + let compendium = Compendium() + compendium.addAdversary(makeSoldier()) + compendium.removeHomebrewAdversary(id: "ironguard-soldier") + #expect(compendium.homebrewAdversaries.isEmpty) + #expect(compendium.adversary(id: "ironguard-soldier") == nil) + } + + @Test func homebrewEnvironmentAppearsInHomebrewList() { + let compendium = Compendium() + compendium.addEnvironment(makeEnv()) + #expect(compendium.homebrewEnvironments.count == 1) + } + + @Test func removeHomebrewEnvironmentRemovesFromBothLists() { + let compendium = Compendium() + compendium.addEnvironment(makeEnv()) + compendium.removeHomebrewEnvironment(id: "bridge") + #expect(compendium.homebrewEnvironments.isEmpty) + #expect(compendium.environment(id: "bridge") == nil) + } +} diff --git a/.claude/worktrees/agent-a84e8640/Tests/DaggerheartKitTests/EncounterSessionTests.swift b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartKitTests/EncounterSessionTests.swift new file mode 100644 index 0000000..344cb69 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartKitTests/EncounterSessionTests.swift @@ -0,0 +1,531 @@ +// +// EncounterSessionTests.swift +// DaggerheartKitTests +// +// Unit tests for EncounterSession mutations, AdversarySlot stat snapshots, +// PlayerSlot session integration, and EncounterSession factory (start from definition). +// + +import DaggerheartModels +import Foundation +import Testing + +@testable import DaggerheartKit + +// MARK: - EncounterSession + +@MainActor struct EncounterSessionTests { + + private func makeSession() -> EncounterSession { + EncounterSession(name: "Test Encounter") + } + + private func makeSoldier() -> Adversary { + Adversary( + id: "ironguard-soldier", + name: "Ironguard Soldier", + tier: 1, + type: .bruiser, + flavorText: "A disciplined mercenary.", + difficulty: 11, + thresholdMajor: 5, + thresholdSevere: 10, + hp: 6, + stress: 3, + attackModifier: "+3", + attackName: "Longsword", + attackRange: .veryClose, + damage: "1d10+3 phy" + ) + } + + @Test func addAdversaryPopulatesSlot() { + let session = makeSession() + let soldier = makeSoldier() + session.add(adversary: soldier) + + #expect(session.adversarySlots.count == 1) + #expect(session.adversarySlots[0].currentHP == 6) + #expect(session.adversarySlots[0].currentStress == 0) + #expect(session.adversarySlots[0].isDefeated == false) + } + + @Test func applyDamageReducesHP() { + let session = makeSession() + let soldier = makeSoldier() + session.add(adversary: soldier) + let slot = session.adversarySlots[0] + + session.applyDamage(4, to: slot) + #expect(session.adversarySlots[0].currentHP == 2) + } + + @Test func applyDamageToZeroMarksDefeated() { + let session = makeSession() + let soldier = makeSoldier() + session.add(adversary: soldier) + let slot = session.adversarySlots[0] + + session.applyDamage(100, to: slot) + #expect(session.adversarySlots[0].currentHP == 0) + #expect(session.adversarySlots[0].isDefeated == true) + #expect(session.activeAdversaries.isEmpty) + } + + @Test func fearAndHopeMutations() { + let session = makeSession() + session.incrementFear(by: 3) + #expect(session.fearPool == 3) + + session.spendFear(2) + #expect(session.fearPool == 1) + + session.spendFear(10) // clamped + #expect(session.fearPool == 0) + + session.incrementHope(by: 5) + session.spendHope(2) + #expect(session.hopePool == 3) + } + + @Test func isOverWhenAllDefeated() { + let session = makeSession() + let soldier = makeSoldier() + session.add(adversary: soldier) + #expect(session.isOver == false) + + let slot = session.adversarySlots[0] + session.applyDamage(999, to: slot) + #expect(session.isOver == true) + } + + @Test func spotlightCountStartsAtZero() { + let session = makeSession() + #expect(session.spotlightCount == 0) + } + + @Test func spotlightIncrementsSporlightCount() { + let session = makeSession() + session.add(adversary: makeSoldier()) + let slot = session.adversarySlots[0] + + session.spotlight(slot) + #expect(session.spotlightCount == 1) + #expect(session.spotlightedSlotID == slot.id) + + session.yieldSpotlight() + #expect(session.spotlightedSlotID == nil) + #expect(session.spotlightCount == 1) // count doesn't reset on yield + } + + @Test func spotlightMultipleTimesAccumulates() { + let session = makeSession() + session.add(adversary: makeSoldier()) + session.add(adversary: makeSoldier()) + let first = session.adversarySlots[0] + let second = session.adversarySlots[1] + + session.spotlight(first) + session.spotlight(second) + #expect(session.spotlightCount == 2) + #expect(session.spotlightedSlotID == second.id) + } + + // MARK: Adversary Conditions + + @Test func adversarySlotStartsWithNoConditions() { + let session = makeSession() + session.add(adversary: makeSoldier()) + #expect(session.adversarySlots[0].conditions.isEmpty) + } + + @Test func applyConditionToAdversarySlot() { + let session = makeSession() + session.add(adversary: makeSoldier()) + let slot = session.adversarySlots[0] + + session.applyCondition(.restrained, to: slot) + #expect(session.adversarySlots[0].conditions.contains(.restrained)) + } + + @Test func removeConditionFromAdversarySlot() { + let session = makeSession() + session.add(adversary: makeSoldier()) + let slot = session.adversarySlots[0] + + session.applyCondition(.hidden, to: slot) + session.removeCondition(.hidden, from: slot) + #expect(!session.adversarySlots[0].conditions.contains(.hidden)) + } + + @Test func conditionsDoNotStack() { + let session = makeSession() + session.add(adversary: makeSoldier()) + let slot = session.adversarySlots[0] + + session.applyCondition(.vulnerable, to: slot) + session.applyCondition(.vulnerable, to: slot) + #expect(session.adversarySlots[0].conditions.count == 1) + } + + @Test func emptyCustomConditionOnAdversaryIsRejected() { + let session = makeSession() + session.add(adversary: makeSoldier()) + let slot = session.adversarySlots[0] + + session.applyCondition(.custom(""), to: slot) + #expect(session.adversarySlots[0].conditions.isEmpty) + } + + @Test func whitespaceCustomConditionOnAdversaryIsRejected() { + let session = makeSession() + session.add(adversary: makeSoldier()) + let slot = session.adversarySlots[0] + + session.applyCondition(.custom(" "), to: slot) + #expect(session.adversarySlots[0].conditions.isEmpty) + } + + @Test func customConditionOnAdversarySlot() { + let session = makeSession() + session.add(adversary: makeSoldier()) + let slot = session.adversarySlots[0] + + session.applyCondition(.custom("Enraged"), to: slot) + #expect(session.adversarySlots[0].conditions.contains(.custom("Enraged"))) + } +} + +// MARK: - PlayerSlot Session Integration + +@MainActor struct PlayerSlotSessionTests { + + private func makeSession() -> EncounterSession { + EncounterSession(name: "Test Encounter") + } + + private func makeSoldier() -> Adversary { + Adversary( + id: "ironguard-soldier", + name: "Ironguard Soldier", + tier: 1, + type: .bruiser, + flavorText: "A disciplined mercenary.", + difficulty: 11, + thresholdMajor: 5, + thresholdSevere: 10, + hp: 6, + stress: 3, + attackModifier: "+3", + attackName: "Longsword", + attackRange: .veryClose, + damage: "1d10+3 phy" + ) + } + + private func makePlayer() -> PlayerSlot { + PlayerSlot( + name: "Aldric", + maxHP: 6, + maxStress: 6, + evasion: 12, + thresholdMajor: 8, + thresholdSevere: 15, + armorSlots: 3 + ) + } + + @Test func addPlayerSlotToSession() { + let session = makeSession() + session.addPlayer(makePlayer()) + #expect(session.playerSlots.count == 1) + #expect(session.playerSlots[0].name == "Aldric") + } + + @Test func spotlightCyclesThroughBothSlotTypes() { + let session = makeSession() + session.add(adversary: makeSoldier()) + session.addPlayer(makePlayer()) + + let adversarySlot = session.adversarySlots[0] + let playerSlot = session.playerSlots[0] + + session.spotlight(adversarySlot) + #expect(session.spotlightedSlotID == adversarySlot.id) + + session.spotlight(playerSlot) + #expect(session.spotlightedSlotID == playerSlot.id) + } + + @Test func applyDamageToPlayerSlot() { + let session = makeSession() + session.addPlayer(makePlayer()) + let slot = session.playerSlots[0] + + session.applyDamage(2, to: slot) + #expect(session.playerSlots[0].currentHP == 4) + } + + @Test func playerDamageClampsToZero() { + let session = makeSession() + session.addPlayer(makePlayer()) + let slot = session.playerSlots[0] + + session.applyDamage(100, to: slot) + #expect(session.playerSlots[0].currentHP == 0) + } + + @Test func applyStressToPlayerSlot() { + let session = makeSession() + session.addPlayer(makePlayer()) + let slot = session.playerSlots[0] + + session.applyStress(3, to: slot) + #expect(session.playerSlots[0].currentStress == 3) + } + + @Test func playerStressClampsToMax() { + let session = makeSession() + session.addPlayer(makePlayer()) + let slot = session.playerSlots[0] + + session.applyStress(100, to: slot) + #expect(session.playerSlots[0].currentStress == 6) + } + + @Test func healPlayerSlot() { + let session = makeSession() + session.addPlayer(makePlayer()) + let slot = session.playerSlots[0] + + session.applyDamage(4, to: slot) + session.heal(2, to: slot) + #expect(session.playerSlots[0].currentHP == 4) + } + + @Test func healPlayerClampsToMax() { + let session = makeSession() + session.addPlayer(makePlayer()) + let slot = session.playerSlots[0] + + session.applyDamage(2, to: slot) + session.heal(100, to: slot) + #expect(session.playerSlots[0].currentHP == 6) + } + + @Test func reducePlayerStress() { + let session = makeSession() + session.addPlayer(makePlayer()) + let slot = session.playerSlots[0] + + session.applyStress(4, to: slot) + session.reduceStress(2, to: slot) + #expect(session.playerSlots[0].currentStress == 2) + } + + @Test func markArmorSlotOnPlayer() { + let session = makeSession() + session.addPlayer(makePlayer()) + let slotID = session.playerSlots[0].id + + session.markArmorSlot(slotID) + #expect(session.playerSlots[0].currentArmorSlots == 2) + } + + @Test func markArmorSlotClampsToZero() { + let session = makeSession() + var player = makePlayer() + player = PlayerSlot( + name: player.name, maxHP: player.maxHP, maxStress: player.maxStress, + evasion: player.evasion, thresholdMajor: player.thresholdMajor, + thresholdSevere: player.thresholdSevere, armorSlots: 1 + ) + session.addPlayer(player) + let slotID = session.playerSlots[0].id + + session.markArmorSlot(slotID) + session.markArmorSlot(slotID) // already at 0 + #expect(session.playerSlots[0].currentArmorSlots == 0) + } + + @Test func emptyCustomConditionOnPlayerIsRejected() { + let session = makeSession() + session.addPlayer(makePlayer()) + let slot = session.playerSlots[0] + + session.applyCondition(.custom(""), to: slot) + #expect(session.playerSlots[0].conditions.isEmpty) + } + + @Test func applyConditionToPlayerSlot() { + let session = makeSession() + session.addPlayer(makePlayer()) + let slot = session.playerSlots[0] + + session.applyCondition(.vulnerable, to: slot) + #expect(session.playerSlots[0].conditions.contains(.vulnerable)) + } + + @Test func removeConditionFromPlayerSlot() { + let session = makeSession() + session.addPlayer(makePlayer()) + let slot = session.playerSlots[0] + + session.applyCondition(.hidden, to: slot) + session.removeCondition(.hidden, from: slot) + #expect(!session.playerSlots[0].conditions.contains(.hidden)) + } + + @Test func removePlayerFromSession() { + let session = makeSession() + session.addPlayer(makePlayer()) + let slotID = session.playerSlots[0].id + + session.removePlayer(id: slotID) + #expect(session.playerSlots.isEmpty) + #expect(session.spotlightedSlotID == nil) + } +} + +// MARK: - EncounterSession Factory + +@MainActor struct EncounterSessionFactoryTests { + + private func makeCompendium() -> Compendium { + let comp = Compendium() + comp.addAdversary( + Adversary( + id: "ironguard-soldier", name: "Ironguard Soldier", + tier: 1, type: .bruiser, flavorText: "A disciplined mercenary.", + difficulty: 11, thresholdMajor: 5, thresholdSevere: 10, + hp: 6, stress: 3, attackModifier: "+3", attackName: "Longsword", + attackRange: .veryClose, damage: "1d10+3 phy" + )) + comp.addEnvironment( + DaggerheartEnvironment( + id: "collapsing-bridge", name: "Collapsing Bridge", + flavorText: "A rope-and-plank bridge." + )) + return comp + } + + @Test func sessionFromDefinitionPopulatesSlots() { + let compendium = makeCompendium() + var def = EncounterDefinition(name: "Test Battle") + def.adversaryIDs = ["ironguard-soldier", "ironguard-soldier"] + def.environmentIDs = ["collapsing-bridge"] + def.playerConfigs = [ + PlayerConfig( + name: "Aldric", maxHP: 6, maxStress: 6, + evasion: 12, thresholdMajor: 8, thresholdSevere: 15, armorSlots: 3 + ) + ] + + let session = EncounterSession.start(from: def, using: compendium) + + #expect(session.name == "Test Battle") + #expect(session.adversarySlots.count == 2) + #expect(session.adversarySlots[0].currentHP == 6) + #expect(session.playerSlots.count == 1) + #expect(session.playerSlots[0].name == "Aldric") + #expect(session.environmentSlots.count == 1) + #expect(session.spotlightCount == 0) + #expect(session.fearPool == 0) + } + + @Test func sessionFromDefinitionSkipsUnknownAdversaries() { + let compendium = makeCompendium() + var def = EncounterDefinition(name: "Test") + def.adversaryIDs = ["ironguard-soldier", "nonexistent-creature"] + + let session = EncounterSession.start(from: def, using: compendium) + #expect(session.adversarySlots.count == 1) + } + + @Test func sessionFromDefinitionPreservesGMNotes() { + let compendium = makeCompendium() + var def = EncounterDefinition(name: "Test") + def.gmNotes = "Remember the secret door." + + let session = EncounterSession.start(from: def, using: compendium) + #expect(session.gmNotes == "Remember the secret door.") + } + + @Test func sessionFromDefinitionHasNoInitialSpotlight() { + let compendium = makeCompendium() + var def = EncounterDefinition(name: "Test") + def.adversaryIDs = ["ironguard-soldier"] + def.playerConfigs = [ + PlayerConfig( + name: "Aldric", maxHP: 6, maxStress: 6, + evasion: 12, thresholdMajor: 8, thresholdSevere: 15, armorSlots: 3 + ) + ] + + let session = EncounterSession.start(from: def, using: compendium) + #expect(session.spotlightedSlotID == nil) + #expect(session.spotlightCount == 0) + } +} + +// MARK: - AdversarySlot stat snapshot + +@MainActor struct AdversarySlotSnapshotTests { + + private func makeSoldier() -> Adversary { + Adversary( + id: "ironguard-soldier", name: "Ironguard Soldier", tier: 1, type: .bruiser, + flavorText: "A disciplined mercenary.", difficulty: 11, + thresholdMajor: 5, thresholdSevere: 10, hp: 6, stress: 3, + attackModifier: "+3", attackName: "Longsword", + attackRange: .veryClose, damage: "1d10+3 phy" + ) + } + + @Test func slotSnapshotsMaxHPAndMaxStress() { + let slot = AdversarySlot.from(makeSoldier()) + #expect(slot.maxHP == 6) + #expect(slot.maxStress == 3) + } + + @Test func applyStressClampedToSnapshotMax() { + let session = EncounterSession(name: "Test") + session.add(adversary: makeSoldier()) + let slot = session.adversarySlots[0] + + session.applyStress(100, to: slot) + #expect(session.adversarySlots[0].currentStress == 3) + } + + @Test func applyStressAccumulatesCorrectly() { + let session = EncounterSession(name: "Test") + session.add(adversary: makeSoldier()) + let slot = session.adversarySlots[0] + + session.applyStress(1, to: slot) + session.applyStress(1, to: slot) + #expect(session.adversarySlots[0].currentStress == 2) + } + + @Test func healClampedToSnapshotMaxHP() { + let session = EncounterSession(name: "Test") + session.add(adversary: makeSoldier()) + let slot = session.adversarySlots[0] + + session.applyDamage(4, to: slot) + session.heal(100, to: slot) + #expect(session.adversarySlots[0].currentHP == 6) + } + + @Test func healFromZeroUnsetsDefeated() { + let session = EncounterSession(name: "Test") + session.add(adversary: makeSoldier()) + let slot = session.adversarySlots[0] + + session.applyDamage(999, to: slot) + #expect(session.adversarySlots[0].isDefeated == true) + session.heal(6, to: slot) + #expect(session.adversarySlots[0].isDefeated == false) + #expect(session.adversarySlots[0].currentHP == 6) + } +} diff --git a/.claude/worktrees/agent-a84e8640/Tests/DaggerheartKitTests/EncounterStoreTests.swift b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartKitTests/EncounterStoreTests.swift new file mode 100644 index 0000000..1a18dd2 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartKitTests/EncounterStoreTests.swift @@ -0,0 +1,257 @@ +// +// EncounterStoreTests.swift +// DaggerheartKitTests +// +// Unit tests for EncounterStore: create, save, delete, duplicate, load. +// Also covers EncounterStoreError descriptions. +// + +import DaggerheartModels +import Foundation +import Testing + +@testable import DaggerheartKit + +// MARK: - EncounterStoreError + +struct EncounterStoreErrorTests { + + @Test func notFoundDescription() { + let id = UUID() + let error = EncounterStoreError.notFound(id) + #expect(error.errorDescription == "No encounter definition found with ID \(id).") + } + + @Test func saveFailedDescription() { + let id = UUID() + let underlying = CocoaError(.fileWriteNoPermission) + let error = EncounterStoreError.saveFailed(id, underlying) + #expect(error.errorDescription?.hasPrefix("Failed to save encounter \(id):") == true) + } + + @Test func deleteFailedDescription() { + let id = UUID() + let underlying = CocoaError(.fileNoSuchFile) + let error = EncounterStoreError.deleteFailed(id, underlying) + #expect(error.errorDescription?.hasPrefix("Failed to delete encounter \(id):") == true) + } +} + +@MainActor struct EncounterStoreTests { + + private func makeStore() throws -> EncounterStore { + let dir = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return EncounterStore(directory: dir) + } + + // MARK: create + + @Test func createAddsToDefinitions() async throws { + let store = try makeStore() + try await store.create(name: "Bandit Camp") + #expect(store.definitions.count == 1) + #expect(store.definitions[0].name == "Bandit Camp") + } + + @Test func createPersistsToFile() async throws { + let store = try makeStore() + try await store.create(name: "Bandit Camp") + + let store2 = EncounterStore(directory: store.directory) + await store2.load() + #expect(store2.definitions.count == 1) + #expect(store2.definitions[0].name == "Bandit Camp") + } + + @Test func createMultipleProducesDistinctDefinitions() async throws { + let store = try makeStore() + try await store.create(name: "Encounter A") + try await store.create(name: "Encounter B") + #expect(store.definitions.count == 2) + let ids = store.definitions.map(\.id) + #expect(Set(ids).count == 2) + } + + // MARK: save + + @Test func savePersistsMutations() async throws { + let store = try makeStore() + try await store.create(name: "Original") + var def = store.definitions[0] + def.name = "Updated" + def.gmNotes = "Remember the trap." + try await store.save(def) + + let store2 = EncounterStore(directory: store.directory) + await store2.load() + #expect(store2.definitions.count == 1) + #expect(store2.definitions[0].name == "Updated") + #expect(store2.definitions[0].gmNotes == "Remember the trap.") + } + + @Test func saveUpdatesInMemoryDefinition() async throws { + let store = try makeStore() + try await store.create(name: "Original") + var def = store.definitions[0] + def.name = "Renamed" + try await store.save(def) + #expect(store.definitions[0].name == "Renamed") + } + + @Test func saveUnknownIDThrows() async throws { + let store = try makeStore() + let orphan = EncounterDefinition(name: "Ghost") + await #expect(throws: (any Error).self) { + try await store.save(orphan) + } + } + + // MARK: delete + + @Test func deleteRemovesFromDefinitions() async throws { + let store = try makeStore() + try await store.create(name: "Encounter A") + try await store.create(name: "Encounter B") + let idToDelete = try #require(store.definitions.first(where: { $0.name == "Encounter A" })).id + + try await store.delete(id: idToDelete) + #expect(store.definitions.count == 1) + #expect(store.definitions[0].name == "Encounter B") + } + + @Test func deleteRemovesFileFromDisk() async throws { + let store = try makeStore() + try await store.create(name: "Temp") + let id = store.definitions[0].id + try await store.delete(id: id) + + let store2 = EncounterStore(directory: store.directory) + await store2.load() + #expect(store2.definitions.isEmpty) + } + + @Test func deleteUnknownIDThrows() async throws { + let store = try makeStore() + await #expect(throws: (any Error).self) { + try await store.delete(id: UUID()) + } + } + + // MARK: duplicate + + @Test func duplicateCreatesIndependentCopy() async throws { + let store = try makeStore() + try await store.create(name: "Original") + var def = store.definitions[0] + def.gmNotes = "Some notes." + try await store.save(def) + + try await store.duplicate(id: def.id) + #expect(store.definitions.count == 2) + + let copy = try #require(store.definitions.first(where: { $0.id != def.id })) + #expect(copy.name == "Original (Copy)") + #expect(copy.gmNotes == "Some notes.") + #expect(copy.createdAt >= def.createdAt) + } + + @Test func duplicateMutationDoesNotAffectOriginal() async throws { + let store = try makeStore() + try await store.create(name: "Original") + let originalID = store.definitions[0].id + + try await store.duplicate(id: originalID) + let copyID = try #require(store.definitions.first(where: { $0.id != originalID })).id + + var copy = try #require(store.definitions.first(where: { $0.id == copyID })) + copy.name = "Mutated Copy" + try await store.save(copy) + + let original = try #require(store.definitions.first(where: { $0.id == originalID })) + #expect(original.name == "Original") + } + + @Test func duplicateUnknownIDThrows() async throws { + let store = try makeStore() + await #expect(throws: (any Error).self) { + try await store.duplicate(id: UUID()) + } + } + + // MARK: load + + @Test func loadReconstitutesFromFiles() async throws { + let store = try makeStore() + let def = EncounterDefinition(name: "From File") + try JSONEncoder().encode(def).write( + to: store.directory.appending(path: "\(def.id.uuidString).encounter.json") + ) + await store.load() + #expect(store.definitions.count == 1) + #expect(store.definitions[0].name == "From File") + #expect(store.definitions[0].id == def.id) + } + + @Test func loadIgnoresNonEncounterFiles() async throws { + let store = try makeStore() + try Data("noise".utf8).write(to: store.directory.appending(path: "readme.txt")) + await store.load() + #expect(store.definitions.isEmpty) + } + + @Test func loadIgnoresCorruptFiles() async throws { + let store = try makeStore() + let def = EncounterDefinition(name: "Good Encounter") + try JSONEncoder().encode(def).write( + to: store.directory.appending(path: "\(def.id.uuidString).encounter.json") + ) + try Data("not valid json".utf8).write( + to: store.directory.appending(path: "corrupt.encounter.json") + ) + await store.load() + #expect(store.definitions.count == 1) + #expect(store.definitions[0].name == "Good Encounter") + } + + // MARK: sort order + + @Test func definitionsSortedByModifiedAtDescending() async throws { + let store = try makeStore() + try await store.create(name: "Alpha") + + var alpha = store.definitions[0] + let now = Date.now + let earlier = EncounterDefinition( + id: UUID(), name: "Beta", + createdAt: now.addingTimeInterval(-60), + modifiedAt: now.addingTimeInterval(-60) + ) + let latest = EncounterDefinition( + id: UUID(), name: "Gamma", + createdAt: now.addingTimeInterval(-10), + modifiedAt: now.addingTimeInterval(-10) + ) + let encoder = JSONEncoder() + try encoder.encode(earlier).write( + to: store.directory.appending(path: "\(earlier.id.uuidString).encounter.json") + ) + try encoder.encode(latest).write( + to: store.directory.appending(path: "\(latest.id.uuidString).encounter.json") + ) + alpha.gmNotes = "touched last" + try await store.save(alpha) + await store.load() + + #expect(store.definitions.count == 3) + #expect(store.definitions[0].name == "Alpha") + #expect(store.definitions[1].name == "Gamma") + #expect(store.definitions[2].name == "Beta") + + let dates = store.definitions.map(\.modifiedAt) + for i in 0..<(dates.count - 1) { + #expect(dates[i] >= dates[i + 1], "definitions[\(i)] should be >= definitions[\(i+1)]") + } + } +} diff --git a/.claude/worktrees/agent-a84e8640/Tests/DaggerheartKitTests/SessionRegistryTests.swift b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartKitTests/SessionRegistryTests.swift new file mode 100644 index 0000000..454c3fa --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartKitTests/SessionRegistryTests.swift @@ -0,0 +1,103 @@ +// +// SessionRegistryTests.swift +// DaggerheartKitTests +// +// Unit tests for SessionRegistry: session creation, caching, clearing, +// and resetSession behavior. +// + +import DaggerheartModels +import Foundation +import Testing + +@testable import DaggerheartKit + +@MainActor struct SessionRegistryTests { + + private func makeCompendium() -> Compendium { + let c = Compendium() + c.addAdversary( + Adversary( + id: "goblin", name: "Goblin", tier: 1, type: .minion, + flavorText: "Small and cunning.", difficulty: 10, + thresholdMajor: 5, thresholdSevere: 10, hp: 3, stress: 2, + attackModifier: "+2", attackName: "Rusty Blade", + attackRange: .veryClose, damage: "1d4 phy" + )) + return c + } + + private func makeDefinition(adversaryIDs: [String] = ["goblin"]) -> EncounterDefinition { + EncounterDefinition(name: "Test Battle", adversaryIDs: adversaryIDs) + } + + @Test func sessionIsCreatedOnFirstAccess() { + let registry = SessionRegistry() + let compendium = makeCompendium() + let def = makeDefinition() + + let session = registry.session(for: def.id, definition: def, compendium: compendium) + #expect(session.adversarySlots.count == 1) + } + + @Test func sameSessionReturnedOnSubsequentCalls() { + let registry = SessionRegistry() + let compendium = makeCompendium() + let def = makeDefinition() + + let s1 = registry.session(for: def.id, definition: def, compendium: compendium) + let s2 = registry.session(for: def.id, definition: def, compendium: compendium) + #expect(s1 === s2) + } + + @Test func clearSessionRemovesEntry() { + let registry = SessionRegistry() + let compendium = makeCompendium() + let def = makeDefinition() + + _ = registry.session(for: def.id, definition: def, compendium: compendium) + registry.clearSession(for: def.id) + #expect(registry.sessions[def.id] == nil) + } + + @Test func resetSessionCreatesNewSession() { + let registry = SessionRegistry() + let compendium = makeCompendium() + let def1 = makeDefinition(adversaryIDs: ["goblin"]) + + let s1 = registry.session(for: def1.id, definition: def1, compendium: compendium) + s1.applyDamage(2, to: s1.adversarySlots[0]) + #expect(s1.adversarySlots[0].currentHP == 1) + + let s2 = registry.resetSession(for: def1.id, definition: def1, compendium: compendium) + + #expect(s2 !== s1) + #expect(s2.adversarySlots[0].currentHP == 3) + } + + @Test func resetSessionReplacesStoredSession() { + let registry = SessionRegistry() + let compendium = makeCompendium() + let def = makeDefinition() + + let s1 = registry.session(for: def.id, definition: def, compendium: compendium) + let s2 = registry.resetSession(for: def.id, definition: def, compendium: compendium) + + let s3 = registry.session(for: def.id, definition: def, compendium: compendium) + #expect(s3 === s2) + #expect(s3 !== s1) + } + + @Test func resetSessionReflectsNewDefinition() { + let registry = SessionRegistry() + let compendium = makeCompendium() + let def1 = makeDefinition(adversaryIDs: ["goblin"]) + var def2 = def1 + def2.adversaryIDs = ["goblin", "goblin"] + + _ = registry.session(for: def1.id, definition: def1, compendium: compendium) + let s2 = registry.resetSession(for: def2.id, definition: def2, compendium: compendium) + + #expect(s2.adversarySlots.count == 2) + } +} diff --git a/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/ContentModelTests.swift b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/ContentModelTests.swift new file mode 100644 index 0000000..8405cbb --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/ContentModelTests.swift @@ -0,0 +1,220 @@ +// +// ContentModelTests.swift +// DaggerheartModelsTests +// +// Unit tests for ContentSource (backoff), ContentFingerprint (coding), +// and DHPackContent (dual-format decoding). +// All tested types are pure value types with no I/O dependencies. +// + +import Foundation +import Testing + +@testable import DaggerheartModels + +// MARK: - ContentSource backoff + +struct ContentSourceBackoffTests { + + private func makeSource() -> ContentSource { + ContentSource( + id: "test-source", name: "Test", url: URL(string: "https://example.com/pack.dhpack")!) + } + + @Test func firstFailureIsOneHour() { + let now = Date() + let source = makeSource().recordingFailure(at: now) + let expectedDelay: TimeInterval = 3_600 // 1h × 2^0 + let actual = source.nextAllowedFetch!.timeIntervalSince(now) + #expect(abs(actual - expectedDelay) < 1) + #expect(source.consecutiveFailures == 1) + } + + @Test func secondFailureIsTwoHours() { + let now = Date() + let source = makeSource() + .recordingFailure(at: now) + .recordingFailure(at: now) + let expectedDelay: TimeInterval = 7_200 // 1h × 2^1 + let actual = source.nextAllowedFetch!.timeIntervalSince(now) + #expect(abs(actual - expectedDelay) < 1) + #expect(source.consecutiveFailures == 2) + } + + @Test func tenthFailureCapsAtSevenDays() { + let now = Date() + var source = makeSource() + for _ in 0..<10 { source = source.recordingFailure(at: now) } + let sevenDays: TimeInterval = 7 * 24 * 3_600 + let actual = source.nextAllowedFetch!.timeIntervalSince(now) + #expect(abs(actual - sevenDays) < 1) + #expect(source.consecutiveFailures == 10) + } + + @Test func successResetsBackoff() { + let now = Date() + let fingerprint = ContentFingerprint(sha256: "abc123") + let source = makeSource() + .recordingFailure(at: now) + .recordingFailure(at: now) + .recordingSuccess(fingerprint: fingerprint, at: now) + #expect(source.consecutiveFailures == 0) + #expect(source.nextAllowedFetch == nil) + #expect(source.lastFetched != nil) + #expect(source.fingerprint == fingerprint) + } + + @Test func isThrottledReturnsTrueWhileBackoffActive() { + let now = Date() + let source = makeSource().recordingFailure(at: now) + #expect(source.isThrottled(at: now.addingTimeInterval(60))) // 1 min later → still throttled + #expect(!source.isThrottled(at: now.addingTimeInterval(4_000))) // after 1h → clear + } + + @Test func isThrottledFalseBeforeAnyFailure() { + #expect(!makeSource().isThrottled()) + } + + @Test func localImportIsNeverThrottled() { + let source = ContentSource(id: "imported", name: "Imported Pack") + #expect(source.isLocalImport) + #expect(!source.isThrottled()) + #expect(source.url == nil) + } + + @Test func localImportHasLastFetchedSet() throws { + let before = Date() + let source = ContentSource(id: "imported", name: "Imported Pack") + let after = Date() + let fetched = try #require(source.lastFetched) + #expect(fetched >= before && fetched <= after) + } +} + +// MARK: - ContentFingerprint coding + +struct ContentFingerprintTests { + + @Test func roundTripWithEtag() throws { + let original = ContentFingerprint(sha256: "deadbeef1234", etag: "\"v1.2.3\"") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ContentFingerprint.self, from: data) + #expect(decoded == original) + } + + @Test func roundTripWithoutEtag() throws { + let original = ContentFingerprint(sha256: "abc123", etag: nil) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ContentFingerprint.self, from: data) + #expect(decoded == original) + #expect(decoded.etag == nil) + } + + @Test func equalityIgnoresNilVsAbsentEtag() { + let a = ContentFingerprint(sha256: "abc", etag: nil) + let b = ContentFingerprint(sha256: "abc", etag: nil) + #expect(a == b) + } + + @Test func differentSha256IsNotEqual() { + let a = ContentFingerprint(sha256: "aaa", etag: "v1") + let b = ContentFingerprint(sha256: "bbb", etag: "v1") + #expect(a != b) + } +} + +// MARK: - DHPackContent dual-format decoding + +struct DHPackContentTests { + + private let adversaryJSON = """ + { + "id": "cave-bat", + "name": "Cave Bat", + "tier": 1, + "type": "Minion", + "description": "A frantic bat.", + "difficulty": 10, + "thresholds": "None", + "hp": 2, + "stress": 1, + "atk": "+1", + "attack": "Bite", + "range": "Melee", + "damage": "1d4 phy" + } + """ + + private let environmentJSON = """ + { + "id": "dark-cave", + "name": "Dark Cave", + "description": "Pitch black.", + "feature": [] + } + """ + + @Test func decodesKeyedObjectWithBoth() throws { + let json = """ + { + "adversaries": [\(adversaryJSON)], + "environments": [\(environmentJSON)] + } + """.data(using: .utf8)! + let pack = try JSONDecoder().decode(DHPackContent.self, from: json) + #expect(pack.adversaries.count == 1) + #expect(pack.adversaries[0].id == "cave-bat") + #expect(pack.environments.count == 1) + #expect(pack.environments[0].id == "dark-cave") + } + + @Test func decodesKeyedObjectAdversariesOnly() throws { + let json = """ + { "adversaries": [\(adversaryJSON)] } + """.data(using: .utf8)! + let pack = try JSONDecoder().decode(DHPackContent.self, from: json) + #expect(pack.adversaries.count == 1) + #expect(pack.environments.isEmpty) + } + + @Test func decodesKeyedObjectEnvironmentsOnly() throws { + let json = """ + { "environments": [\(environmentJSON)] } + """.data(using: .utf8)! + let pack = try JSONDecoder().decode(DHPackContent.self, from: json) + #expect(pack.adversaries.isEmpty) + #expect(pack.environments.count == 1) + } + + @Test func decodesBareAdversaryArray() throws { + let json = "[\(adversaryJSON)]".data(using: .utf8)! + let pack = try JSONDecoder().decode(DHPackContent.self, from: json) + #expect(pack.adversaries.count == 1) + #expect(pack.adversaries[0].name == "Cave Bat") + #expect(pack.environments.isEmpty) + } + + @Test func emptyKeyedObjectProducesEmptyPack() throws { + let json = "{}".data(using: .utf8)! + let pack = try JSONDecoder().decode(DHPackContent.self, from: json) + #expect(pack.adversaries.isEmpty) + #expect(pack.environments.isEmpty) + } + + @Test func adversarySourceNormalizedToLowercase() throws { + let json = """ + { "adversaries": [ + { + "name": "Test Beast", + "source": "MyPack", + "tier": 1, "type": "Bruiser", "description": "A beast.", + "difficulty": 10, "thresholds": "5/10", + "hp": 6, "stress": 2, "atk": "+2", + "attack": "Claws", "range": "Melee", "damage": "1d8 phy" + } + ]} + """.data(using: .utf8)! + let pack = try JSONDecoder().decode(DHPackContent.self, from: json) + #expect(pack.adversaries[0].source == "mypack") + } +} diff --git a/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/Fixtures/adversaries.json b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/Fixtures/adversaries.json new file mode 100644 index 0000000..8f35e49 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/Fixtures/adversaries.json @@ -0,0 +1,3946 @@ +[ + { + "atk": "+3", + "attack": "Claws", + "damage": "1d12+2 phy", + "description": "A horse-sized insect with digging claws and acidic blood.", + "difficulty": "14", + "experience": "Tremor Sense +2", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Burrower can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Earth Eruption - Action", + "text": "**Mark a Stress** to have the Burrower burst out of the ground. All creatures within Very Close range must succeed on an Agility Reaction Roll or be knocked over, making them _Vulnerable_ until they next act." + }, + { + "name": "Spit Acid - Action", + "text": "Make an attack against all targets in front of the Burrower within Close range. Targets the Burrower succeeds against take **2d6** physical damage and must mark an Armor Slot without receiving its benefits (they can still use armor to reduce the damage). If they can't mark an Armor Slot, they must mark an additional HP and you gain a Fear." + }, + { + "name": "Acid Bath - Reaction", + "text": "When the Burrower takes Severe damage, all creatures within Close range are bathed in their acidic blood, taking **1d10** physical damage. This splash covers the ground within Very Close range with blood, and all creatures other than the Burrower who move through it take **1d6** physical damage." + } + ], + "hp": "8", + "motives_and_tactics": "Burrow, drag away, feed, reposition", + "name": "Acid Burrower", + "range": "Very Close", + "stress": "3", + "thresholds": "8/15", + "tier": "1", + "type": "Solo" + }, + { + "atk": "+1", + "attack": "Claws", + "damage": "1d8+3 phy", + "description": "A large bear with thick fur and powerful claws.", + "difficulty": "14", + "experience": "Ambusher +3, Keen Senses +2", + "feature": [ + { + "name": "Overwhelming Force - Passive", + "text": "Targets who mark HP from the Bear's standard attack are knocked back to Very Close range." + }, + { + "name": "Bite - Action", + "text": "**Mark a Stress** to make an attack against a target within Melee range. On a success, deal **3d4+10** physical damage and the target is _Restrained_ until they break free with a successful Strength Roll." + }, + { + "name": "Momentum - Reaction", + "text": "When the Bear makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Climb, defend territory, pummel, track", + "name": "Bear", + "range": "Melee", + "stress": "2", + "thresholds": "9/17", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+1", + "attack": "Club", + "damage": "1d10+2 phy", + "description": "A massive humanoid who sees all sapient life as food.", + "difficulty": "13", + "experience": "Throw +2", + "feature": [ + { + "name": "Ramp Up - Passive", + "text": "You must **spend a Fear** to spotlight the Ogre. While spotlighted, they can make their standard attack against all targets within range." + }, + { + "name": "Bone Breaker - Passive", + "text": "The Ogre's attacks deal direct damage." + }, + { + "name": "Hail of Boulders - Action", + "text": "**Mark a Stress** to pick up heavy objects and throw them at all targets in front of the Ogre within Far range. Make an attack against these targets. Targets the Ogre succeeds against take **1d10+2** physical damage. If they succeed against more than one target, you gain a Fear." + }, + { + "name": "Rampaging Fury - Reaction", + "text": "When the Ogre marks 2 or more HP, they can rampage. Move the Ogre to a point within Close range and deal **2d6+3** direct physical damage to all targets in their path." + } + ], + "hp": "8", + "motives_and_tactics": "Bite off heads, feast, rip limbs, stomp, throw enemies", + "name": "Cave Ogre", + "range": "Very Close", + "stress": "3", + "thresholds": "8/15", + "tier": "1", + "type": "Solo" + }, + { + "atk": "+4", + "attack": "Fist Slam", + "damage": "1d20 phy", + "description": "A roughly humanoid being of stone and steel, assembled and animated by magic.", + "difficulty": "13", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Construct can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Weak Structure - Passive", + "text": "When the Construct marks HP from physical damage, they must mark an additional HP." + }, + { + "name": "Trample - Action", + "text": "**Mark a Stress** to make an attack against all targets in the Construct's path when they move. Targets the Construct succeeds against take **1d8** physical damage." + }, + { + "name": "Overload - Reaction", + "text": "Before rolling damage for the Construct's attack, you can **mark a Stress** to gain a +10 bonus to the damage roll. The Construct can then take the spotlight again." + }, + { + "name": "Death Quake - Reaction", + "text": "When the Construct marks their last HP, the magic powering them ruptures in an explosion of force. Make an attack with advantage against all targets within Very Close range. Targets the Construct succeeds against take **1d12+2** magic damage." + } + ], + "hp": "9", + "motives_and_tactics": "Destroy environment, serve creator, smash target, trample groups", + "name": "Construct", + "range": "Melee", + "stress": "4", + "thresholds": "7/15", + "tier": "1", + "type": "Solo" + }, + { + "atk": "-4", + "attack": "Daggers", + "damage": "1d4+2 phy", + "description": "An ambitious and ostentatiously dressed socialite.", + "difficulty": "12", + "experience": "Socialite +3", + "feature": [ + { + "name": "Mockery - Action", + "text": "**Mark a Stress** to say something mocking and force a target within Close range to make a Presence Reaction Roll (14) to see if they can save face. On a failure, the target must mark 2 Stress and is _Vulnerable_ until the scene ends." + }, + { + "name": "Scapegoat - Action", + "text": "**Spend a Fear** and target a PC. The Courtier convinces a crowd or prominent individual that the target is the cause of their current conflict or misfortune." + } + ], + "hp": "3", + "motives_and_tactics": "Discredit, gain favor, maneuver, scheme", + "name": "Courtier", + "range": "Melee", + "stress": "4", + "thresholds": "4/8", + "tier": "1", + "type": "Social" + }, + { + "atk": "+2", + "attack": "Vines", + "damage": "1d8+3 phy", + "description": "A burly vegetable-person with grasping vines.", + "difficulty": "10", + "experience": "Huge +3", + "feature": [ + { + "name": "Ground Slam - Action", + "text": "Slam the ground, knocking all targets within Very Close range back to Far range. Each target knocked back this way must mark a Stress." + }, + { + "name": "Grab and Drag - Action", + "text": "Make an attack against a target within Close range. On a success, **spend a Fear** to pull them into Melee range, deal **1d6+2** physical damage, and _Restrain_ them until the Defender takes Severe damage." + } + ], + "hp": "7", + "motives_and_tactics": "Ambush, grab, protect, pummel", + "name": "Deeproot Defender", + "range": "Close", + "stress": "3", + "thresholds": "8/14", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+2", + "attack": "Claws", + "damage": "1d6+2 phy", + "description": "A large wolf with menacing teeth, seldom encountered alone.", + "difficulty": "12", + "experience": "Keen Senses +3", + "feature": [ + { + "name": "Pack Tactics - Passive", + "text": "If the Wolf makes a successful standard attack and another Dire Wolf is within Melee range of the target, deal **1d6+5** physical damage instead of their standard damage and you gain a Fear." + }, + { + "name": "Hobbling Strike - Action", + "text": "**Mark a Stress** to make an attack against a target within Melee range. On a success, deal **3d4+10** direct physical damage and make them _Vulnerable_ until they clear at least 1 HP." + } + ], + "hp": "4", + "motives_and_tactics": "Defend territory, harry, protect pack, surround, trail", + "name": "Dire Wolf", + "range": "Melee", + "stress": "3", + "thresholds": "5/9", + "tier": "1", + "type": "Skulk" + }, + { + "atk": "-2", + "attack": "Proboscis", + "damage": "1d8+3 phy", + "description": "Dozens of fist-sized mosquitoes, flying together for protection.", + "difficulty": "10", + "experience": "Camouflage +2", + "feature": [ + { + "name": "Horde (1d4+1) - Passive", + "text": "When the Mosquitoes have marked half or more of their HP, their standard attack deals **1d4+1** physical damage instead." + }, + { + "name": "Flying - Passive", + "text": "While flying, the Mosquitoes have a +2 bonus to their Difficulty." + }, + { + "name": "Bloodsucker - Reaction", + "text": "When the Mosquitoes' attack causes a target to mark HP, you can **mark a Stress** to force the target to mark an additional HP." + } + ], + "hp": "6", + "motives_and_tactics": "Fly away, harass, steal blood", + "name": "Giant Mosquitoes", + "range": "Melee", + "stress": "3", + "thresholds": "5/9", + "tier": "1", + "type": "Horde (5/HP)" + }, + { + "atk": "-4", + "attack": "Claws", + "damage": "1 phy", + "description": "A cat-sized rodent skilled at scavenging and survival.", + "difficulty": "10", + "experience": "Keen Senses +3", + "feature": [ + { + "name": "Minion (3) - Passive", + "text": "The Rat is defeated when they take any damage. For every 3 damage a PC deals to the Rat, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Giant Rats within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 1 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Burrow, hunger, scavenge, wear down", + "name": "Giant Rat", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+1", + "attack": "Pincers", + "damage": "1d12+2 phy", + "description": "A human-sized arachnid with tearing claws and a stinging tail.", + "difficulty": "13", + "experience": "Camouflage +2", + "feature": [ + { + "name": "Double Strike - Action", + "text": "**Mark a Stress** to make a standard attack against two targets within Melee range." + }, + { + "name": "Venomous Stinger - Action", + "text": "Make an attack against a target within Very Close range. On a success, **spend a Fear** to deal **1d4+4** physical damage and _Poison_ them until their next rest or they succeed on a Knowledge Roll (16). While _Poisoned_, the target must roll a **d6** before they make an action roll. On a result of 4 or lower, they must mark a Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Scorpion makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Ambush, feed, grapple, poison", + "name": "Giant Scorpion", + "range": "Melee", + "stress": "3", + "thresholds": "7/13", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+2", + "attack": "Glass Fangs", + "damage": "1d8+2 phy", + "description": "A clear serpent with a massive head that leaves behind a glass shard trail wherever they go.", + "difficulty": "14", + "feature": [ + { + "name": "Armor-Shredding Shards - Passive", + "text": "After a successful attack against the Snake within Melee range, the attacker must mark an Armor Slot. If they can't mark an Armor Slot, they must mark an HP." + }, + { + "name": "Spinning Serpent - Action", + "text": "**Mark a Stress** to make an attack against all targets within Very Close range. Targets the Snake succeeds against take **1d6+1** physical damage." + }, + { + "name": "Spitter - Action", + "text": "**Spend a Fear** to introduce a **d6** Spitter Die. When the Snake is in the spotlight, roll this die. On a result of 5 or higher, all targets in front of the Snake within Far range must succeed on an Agility Reaction Roll or take **1d4** physical damage. The Snake can take the spotlight a second time this GM turn." + } + ], + "hp": "5", + "motives_and_tactics": "Climb, feed, keep distance, scare", + "name": "Glass Snake", + "range": "Very Close", + "stress": "3", + "thresholds": "6/10", + "tier": "1", + "type": "Standard" + }, + { + "atk": "+1", + "attack": "Javelin", + "damage": "1d6+2 phy", + "description": "A nimble fighter armed with javelins.", + "difficulty": "12", + "experience": "Camouflage +2", + "feature": [ + { + "name": "Maintain Distance - Passive", + "text": "After making a standard attack, the Harrier can move anywhere within Far range." + }, + { + "name": "Fall Back - Reaction", + "text": "When a creature moves into Melee range to make an attack, you can **mark a Stress** before the attack roll to move anywhere within Close range and make an attack against that creature. On a success, deal **1d10+2** physical damage." + } + ], + "hp": "3", + "motives_and_tactics": "Flank, harry, kite, profit", + "name": "Harrier", + "range": "Close", + "stress": "3", + "thresholds": "5/9", + "tier": "1", + "type": "Standard" + }, + { + "atk": "+1", + "attack": "Longbow", + "damage": "1d8+3 phy", + "description": "A tall guard bearing a longbow and quiver with arrows fletched in the settlement's colors.", + "difficulty": "10", + "experience": "Local Knowledge +3", + "feature": [ + { + "name": "Hobbling Shot - Action", + "text": "Make an attack against a target within Far range. On a success, **mark a Stress** to deal **1d12+3** physical damage. If the target marks HP from this attack, they have disadvantage on Agility Rolls until they clear at least 1 HP." + } + ], + "hp": "3", + "motives_and_tactics": "Arrest, close gates, make it through the day, pin down", + "name": "Archer Guard", + "range": "Far", + "stress": "2", + "thresholds": "4/8", + "tier": "1", + "type": "Ranged" + }, + { + "atk": "+1", + "attack": "Longsword", + "damage": "1d6+1 phy", + "description": "An armored guard bearing a sword and shield painted in the settlement's colors.", + "difficulty": "12", + "experience": "Local Knowledge +3", + "feature": [ + { + "name": "Shield Wall - Passive", + "text": "A creature who tries to move within Very Close range of the Guard must succeed on an Agility Roll. If additional Bladed Guards are standing in a line alongside the first, and each is within Melee range of another guard in the line, the Difficulty increases by the total number of guards in that line." + }, + { + "name": "Detain - Action", + "text": "Make an attack against a target within Very Close range. On a success, **mark a Stress** to _Restrain_ the target until they break free with a successful attack, Finesse Roll, or Strength Roll." + } + ], + "hp": "5", + "motives_and_tactics": "Arrest, close gates, make it through the day, pin down", + "name": "Bladed Guard", + "range": "Melee", + "stress": "2", + "thresholds": "5/9", + "tier": "1", + "type": "Standard" + }, + { + "atk": "+4", + "attack": "Mace", + "damage": "1d10+4 phy", + "description": "A seasoned guard with a mace, a whistle, and a bellowing voice.", + "difficulty": "15", + "experience": "Commander +2, Local Knowledge +2", + "feature": [ + { + "name": "Rally Guards - Action", + "text": "**Spend 2 Fear** to spotlight the Head Guard and up to **2d4** allies within Far range." + }, + { + "name": "On My Signal - Reaction: Countdown (5)", + "text": "When the Head Guard is in the spotlight for the first time, activate the countdown. It ticks down when a PC makes an attack roll. When it triggers, all Archer Guards within Far range make a standard attack with advantage against the nearest target within their range. If any attacks succeed on the same target, combine their damage." + }, + { + "name": "Momentum - Reaction", + "text": "When the Head Guard makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Arrest, close gates, pin down, seek glory", + "name": "Head Guard", + "range": "Melee", + "stress": "3", + "thresholds": "7/13", + "tier": "1", + "type": "Leader" + }, + { + "atk": "+1", + "attack": "Daggers", + "damage": "1d8+1 phy", + "description": "A cunning criminal in a cloak bearing one of the gang's iconic knives.", + "difficulty": "12", + "experience": "Thief +2", + "feature": [ + { + "name": "Climber - Passive", + "text": "The Bandit climbs just as easily as they run." + }, + { + "name": "From Above - Passive", + "text": "When the Bandit succeeds on a standard attack from above a target, they deal **1d10+1** physical damage instead of their standard damage." + } + ], + "hp": "5", + "motives_and_tactics": "Escape, profit, steal, throw smoke", + "name": "Jagged Knife Bandit", + "range": "Melee", + "stress": "3", + "thresholds": "8/14", + "tier": "1", + "type": "Standard" + }, + { + "atk": "+2", + "attack": "Staff", + "damage": "1d6+2 mag", + "description": "A staff-wielding bandit in a cloak adorned with magical paraphernalia, using curses to vex their foes.", + "difficulty": "13", + "experience": "Magical Knowledge +2", + "feature": [ + { + "name": "Curse - Action", + "text": "Choose a target within Far range and temporarily _Curse_ them. While the target is _Cursed_, you can **mark a Stress** when that target rolls with Hope to make the roll be with Fear instead." + }, + { + "name": "Chaotic Flux - Action", + "text": "Make an attack against up to three targets within Very Close range. **Mark a Stress** to deal **2d6+3** magic damage to targets the Hexer succeeded against." + } + ], + "hp": "4", + "motives_and_tactics": "Command, hex, profit", + "name": "Jagged Knife Hexer", + "range": "Far", + "stress": "4", + "thresholds": "5/9", + "tier": "1", + "type": "Support" + }, + { + "atk": "-3", + "attack": "Club", + "damage": "1d4+6 phy", + "description": "An imposing brawler carrying a large club.", + "difficulty": "12", + "experience": "Thief +2, Unveiled Threats +3", + "feature": [ + { + "name": "I've Got 'Em - Passive", + "text": "Creatures _Restrained_ by the Kneebreaker take double damage from attacks by other adversaries." + }, + { + "name": "Hold Them Down - Action", + "text": "Make an attack against a target within Melee range. On a success, the target takes no damage but is _Restrained_ and _Vulnerable_. The target can break free, clearing both conditions, with a successful Strength Roll or is freed automatically if the Kneebreaker takes Major or greater damage." + } + ], + "hp": "7", + "motives_and_tactics": "Grapple, intimidate, profit, steal", + "name": "Jagged Knife Kneebreaker", + "range": "Melee", + "stress": "4", + "thresholds": "7/14", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "-2", + "attack": "Daggers", + "damage": "2 phy", + "description": "A thief with simple clothes and small daggers, eager to prove themselves.", + "difficulty": "9", + "experience": "Thief +2", + "feature": [ + { + "name": "Minion (3) - Passive", + "text": "The Lackey is defeated when they take any damage. For every 3 damage a PC deals to the Lackey, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Jagged Knife Lackeys within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Escape, profit, throw smoke", + "name": "Jagged Knife Lackey", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+2", + "attack": "Javelin", + "damage": "1d8+3 phy", + "description": "A seasoned bandit in quality leathers with a strong voice and cunning eyes.", + "difficulty": "13", + "experience": "Local Knowledge +2", + "feature": [ + { + "name": "Tactician - Action", + "text": "When you spotlight the Lieutenant, **mark a Stress** to also spotlight two allies within Close range." + }, + { + "name": "More Where That Came From - Action", + "text": "Summon three Jagged Knife Lackeys, who appear at Far range." + }, + { + "name": "Coup de Grace - Action", + "text": "**Spend a Fear** to make an attack against a _Vulnerable_ target within Close range. On a success, deal **2d6+12** physical damage and the target must mark a Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Lieutenant makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Bully, command, profit, reinforce", + "name": "Jagged Knife Lieutenant", + "range": "Close", + "stress": "3", + "thresholds": "7/14", + "tier": "1", + "type": "Leader" + }, + { + "atk": "+1", + "attack": "Daggers", + "damage": "1d4+4 phy", + "description": "A nimble scoundrel bearing a wicked knife and utilizing shadow magic to isolate targets.", + "difficulty": "12", + "experience": "Intrusion +3", + "feature": [ + { + "name": "Backstab - Passive", + "text": "When the Shadow succeeds on a standard attack that has advantage, they deal **1d6+6** physical damage instead of their standard damage." + }, + { + "name": "Cloaked - Action", + "text": "Become _Hidden_ until after the Shadow's next attack. Attacks made while _Hidden_ from this feature have advantage." + } + ], + "hp": "3", + "motives_and_tactics": "Ambush, conceal, divide, profit", + "name": "Jagged Knife Shadow", + "range": "Melee", + "stress": "3", + "thresholds": "4/8", + "tier": "1", + "type": "Skulk" + }, + { + "atk": "-1", + "attack": "Shortbow", + "damage": "1d10+2 phy", + "description": "A lanky bandit striking from cover with a shortbow.", + "difficulty": "13", + "experience": "Stealth +2", + "feature": [ + { + "name": "Unseen Strike - Passive", + "text": "If the Sniper is _Hidden_ when they make a successful standard attack against a target, they deal **1d10+4** physical damage instead of their standard damage." + } + ], + "hp": "3", + "motives_and_tactics": "Ambush, hide, profit, reposition", + "name": "Jagged Knife Sniper", + "range": "Far", + "stress": "2", + "thresholds": "4/7", + "tier": "1", + "type": "Ranged" + }, + { + "atk": "-4", + "attack": "Club", + "damage": "1d4+1 phy", + "description": "A finely dressed trader with a keen eye for financial gain.", + "difficulty": "12", + "experience": "Shrewd Negotiator +3", + "feature": [ + { + "name": "Preferential Treatment - Passive", + "text": "A PC who succeeds on a Presence Roll against the Merchant gains a discount on purchases. A PC who fails on a Presence Roll against the Merchant must pay more and has disadvantage on future Presence Rolls against the Merchant." + }, + { + "name": "The Runaround - Passive", + "text": "When a PC rolls a 14 or lower on a Presence Roll made against the Merchant, they must mark a Stress." + } + ], + "hp": "3", + "motives_and_tactics": "Buy low and sell high, create demand, inflate prices, seek profit", + "name": "Merchant", + "range": "Melee", + "stress": "3", + "thresholds": "4/8", + "tier": "1", + "type": "Social" + }, + { + "atk": "+3", + "attack": "Warp Blast", + "damage": "1d12+6 mag", + "description": "A coruscating mass of uncontrollable magic.", + "difficulty": "14", + "feature": [ + { + "name": "Arcane Form - Passive", + "text": "The Elemental is resistant to magic damage." + }, + { + "name": "Sickening Flux - Action", + "text": "**Mark a HP** to force all targets within Close range to mark a Stress and become _Vulnerable_ until their next rest or they clear a HP." + }, + { + "name": "Remake Reality - Action", + "text": "**Spend a Fear** to transform the area within Very Close range into a different biome. All targets within this area take **2d6+3** direct magic damage." + }, + { + "name": "Magical reflection - Reaction", + "text": "When the Elemental takes damage from an attack within Close range, deal an amount of damage to the attacker equal to half the damage they dealt." + }, + { + "name": "Momentum - Reaction", + "text": "When the Elemental makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Confound, destabilize, transmogrify", + "name": "Minor Chaos Elemental", + "range": "Close", + "stress": "3", + "thresholds": "7/14", + "tier": "1", + "type": "Solo" + }, + { + "atk": "+3", + "attack": "Elemental Blast", + "damage": "1d10+4 mag", + "description": "A living flame the size of a large bonfire.", + "difficulty": "13", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Elemental can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Scorched Earth - Action", + "text": "**Mark a Stress** to choose a point within Far range. The ground within Very Close range of that point immediately bursts into flames. All creatures within this area must make an Agility Reaction Roll. Targets who fail take **2d8** magic damage from the flames. Targets who succeed take half damage." + }, + { + "name": "Explosion - Action", + "text": "**Spend a Fear** to erupt in a fiery explosion. Make an attack against all targets within Close range. Targets the Elemental succeeds against take **1d8** magic damage and are knocked back to Far range." + }, + { + "name": "Consume Kindling - Reaction", + "text": "Three times per scene, when the Elemental moves onto objects that are highly flammable, consume them to clear a HP or a Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Elemental makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "9", + "motives_and_tactics": "Encircle enemies, grow in size, intimidate, start fires", + "name": "Minor Fire Elemental", + "range": "Far", + "stress": "3", + "thresholds": "7/15", + "tier": "1", + "type": "Solo" + }, + { + "atk": "+3", + "attack": "Claws", + "damage": "1d8+6 phy", + "description": "A crimson-hued creature from the Circles Below, consumed by rage against all mortals.", + "difficulty": "14", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Demon can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "All Must Fall - Passive", + "text": "When a PC rolls a failure with Fear while within Close range of the Demon, they lose a Hope." + }, + { + "name": "Hellfire - Action", + "text": "**Spend a Fear** to rain down hellfire within Far range. All targets within the area must make an Agility Reaction Roll. Targets who fail take **1d20+3** magic damage. Targets who succeed take half damage." + }, + { + "name": "Reaper - Reaction", + "text": "Before rolling damage for the Demon's attack, you can **mark a Stress** to gain a bonus to the damage roll equal to the Demon's current number of marked HP." + }, + { + "name": "Momentum - Reaction", + "text": "When the Demon makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "8", + "motives_and_tactics": "Act erratically, corral targets, relish pain, torment", + "name": "Minor Demon", + "range": "Melee", + "stress": "4", + "thresholds": "8/15", + "tier": "1", + "type": "Solo" + }, + { + "atk": "-2", + "attack": "Clawed Branch", + "damage": "4 phy", + "description": "An ambulatory sapling rising up to defend their forest.", + "difficulty": "10", + "feature": [ + { + "name": "Minion (5) - Passive", + "text": "The Treant is defeated when they take any damage. For every 5 damage a PC deals to the Treant, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Minor Treants within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 4 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Crush, overwhelm, protect", + "name": "Minor Treant", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+1", + "attack": "Ooze Appendage", + "damage": "1d6+1 mag", + "description": "A moving mound of translucent green slime.", + "difficulty": "8", + "experience": "Camouflage +3", + "feature": [ + { + "name": "Slow - Passive", + "text": "When you spotlight the Ooze and they don't have a token on their stat block, they can't act yet. Place a token on their stat block and describe what they're preparing to do. When you spotlight the Ooze and they have a token on their stat block, clear the token and they can act." + }, + { + "name": "Acidic Form - Passive", + "text": "When the Ooze makes a successful attack, the target must mark an Armor Slot without receiving its benefits (they can still use armor to reduce the damage). If they can't mark an Armor Slot, they must mark an additional HP." + }, + { + "name": "Envelop - Action", + "text": "Make a standard attack against a target within Melee range. On a success, the Ooze envelops them and the target must mark 2 Stress. The target must mark an additional Stress when they make an action roll. If the Ooze takes Severe damage, the target is freed." + }, + { + "name": "Split - Reaction", + "text": "When the Ooze has 3 or more HP marked, you can **spend a Fear** to split them into two Tiny Green Oozes (with no marked HP or Stress). Immediately spotlight both of them." + } + ], + "hp": "5", + "motives_and_tactics": "Camouflage, consume and multiply, creep up, envelop", + "name": "Green Ooze", + "range": "Melee", + "stress": "2", + "thresholds": "5/10", + "tier": "1", + "type": "Skulk" + }, + { + "atk": "-1", + "attack": "Ooze Appendage", + "damage": "1d4+1 mag", + "description": "A small moving mound of translucent green slime.", + "difficulty": "14", + "feature": [ + { + "name": "Acidic Form - Passive", + "text": "When the Ooze makes a successful attack, the target must mark an Armor Slot without receiving its benefits (they can still use armor to reduce the damage). If they can't mark an Armor Slot, they must mark an additional HP." + } + ], + "hp": "2", + "motives_and_tactics": "Camouflage, creep up", + "name": "Tiny Green Ooze", + "range": "Melee", + "stress": "1", + "thresholds": "4/None", + "tier": "1", + "type": "Skulk" + }, + { + "atk": "+1", + "attack": "Ooze Appendage", + "damage": "1d8+3 mag", + "description": "A moving mound of translucent flaming red slime.", + "difficulty": "10", + "experience": "Camouflage +3", + "feature": [ + { + "name": "Creeping Fire - Passive", + "text": "The Ooze can only move within Very Close range as their normal movement. They light any flammable object they touch on fire." + }, + { + "name": "Ignite - Action", + "text": "Make an attack against a target within Very Close range. On a success, the target takes **1d8** magic damage and is _Ignited_ until they're extinguished with a successful Finesse Roll (14). While _Ignited_, the target takes **1d4** magic damage when they make an action roll." + }, + { + "name": "Split - Reaction", + "text": "When the Ooze has 3 or more HP marked, you can **spend a Fear** to split them into two Tiny Red Oozes (with no marked HP or Stress). Immediately spotlight both of them." + } + ], + "hp": "5", + "motives_and_tactics": "Camouflage, consume and multiply, ignite, start fires", + "name": "Red Ooze", + "range": "Melee", + "stress": "3", + "thresholds": "6/11", + "tier": "1", + "type": "Skulk" + }, + { + "atk": "-1", + "attack": "Ooze Appendage", + "damage": "1d4+2 mag", + "description": "A small moving mound of translucent flaming red slime", + "difficulty": "11", + "feature": [ + { + "name": "Burning - Reaction", + "text": "When a creature within Melee range deals damage to the Ooze, they take **1d6** direct magic damage." + } + ], + "hp": "2", + "motives_and_tactics": "Blaze, Camouflage", + "name": "Tiny Red Ooze", + "range": "Melee", + "stress": "1", + "thresholds": "5/None", + "tier": "1", + "type": "Skulk" + }, + { + "atk": "-3", + "attack": "Rapier", + "damage": "1d6+1 phy", + "description": "A richly dressed and adorned aristocrat brimming with hubris.", + "difficulty": "14", + "experience": "Aristocrat +3", + "feature": [ + { + "name": "My Land, My Rules - Passive", + "text": "All social actions made against the Noble on their land have disadvantage." + }, + { + "name": "Guards, Seize Them! - Action", + "text": "Once per scene, **mark a Stress** to summon **1d4** Bladed Guards, who appear at Far range to enforce the Noble's will." + }, + { + "name": "Exile - Action", + "text": "**Spend a Fear** and target a PC. The Noble proclaims that the target and their allies are exiled from the noble's territory. While exiled, the target and their allies have disadvantage during social situations within the Noble's domain." + } + ], + "hp": "3", + "motives_and_tactics": "Abuse power, gather resources, mobilize minions", + "name": "Petty Noble", + "range": "Melee", + "stress": "5", + "thresholds": "6/10", + "tier": "1", + "type": "Social" + }, + { + "atk": "+4", + "attack": "Cutlass", + "damage": "1d12+2 phy", + "description": "A charismatic sea dog with an impressive hat, eager to raid and plunder.", + "difficulty": "14", + "experience": "Commander +2, Sailor +3", + "feature": [ + { + "name": "Swashbuckler - Passive", + "text": "When the Captain marks 2 or fewer HP from an attack within Melee range, the attacker must mark a Stress." + }, + { + "name": "Reinforcements - Action", + "text": "Once per scene, **mark a Stress** to summon a Pirate Raiders Horde, which appears at Far range." + }, + { + "name": "No Quarter - Action", + "text": "**Spend a Fear** to choose a target who has three or more Pirates within Melee range of them. The Captain leads the Pirates in hurling threats and promises of a watery grave. The target must make a Presence Reaction Roll. On a failure, the target marks **1d4+1** Stress. On a success, they must mark a Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Captain makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Command, make 'em walk the plank, plunder, raid", + "name": "Pirate Captain", + "range": "Melee", + "stress": "5", + "thresholds": "7/14", + "tier": "1", + "type": "Leader" + }, + { + "atk": "+1", + "attack": "Cutlass", + "damage": "1d8+2 phy", + "description": "Seafaring scoundrels moving in a ravaging pack.", + "difficulty": "12", + "experience": "Sailor +3", + "feature": [ + { + "name": "Horde (1d4+1) - Passive", + "text": "When the Raiders have marked half or more of their HP, their standard attack deals **1d4+1** physical damage instead." + }, + { + "name": "Swashbuckler - Passive", + "text": "When the Raiders mark 2 or fewer HP from an attack within Melee range, the attacker must mark a Stress." + } + ], + "hp": "4", + "motives_and_tactics": "Gang up, plunder, raid", + "name": "Pirate Raiders", + "range": "Melee", + "stress": "3", + "thresholds": "5/11", + "tier": "1", + "type": "Horde (3/HP)" + }, + { + "atk": "+1", + "attack": "Massive Fists", + "damage": "2d6 phy", + "description": "A thickly muscled and tattooed pirate with melon-sized fists.", + "difficulty": "13", + "experience": "Sailor +2", + "feature": [ + { + "name": "Swashbuckler - Passive", + "text": "When the Tough marks 2 or fewer HP from an attack within Melee range, the attacker must mark a Stress." + }, + { + "name": "Clear the Decks - Action", + "text": "Make an attack against a target within Very Close range. On a success, **mark a Stress** to move into Melee range of the target, dealing **3d4** physical damage and knocking the target back to Close range." + } + ], + "hp": "5", + "motives_and_tactics": "Plunder, raid, smash, terrorize", + "name": "Pirate Tough", + "range": "Melee", + "stress": "3", + "thresholds": "8/15", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+3", + "attack": "Longsword", + "damage": "3 phy", + "description": "An armed mercenary testing their luck.", + "difficulty": "10", + "feature": [ + { + "name": "Minion (4) - Passive", + "text": "The Sellsword is defeated when they take any damage. For every 4 damage a PC deals to the Sellsword, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Sellswords within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 3 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Charge, lacerate, overwhelm, profit", + "name": "Sellsword", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+2", + "attack": "Shortbow", + "damage": "1d8+1 phy", + "description": "A fragile skeleton with a shortbow and arrows.", + "difficulty": "9", + "feature": [ + { + "name": "Opportunist - Passive", + "text": "When two or more adversaries are within Very Close range of a creature, all damage the Archer deals to that creature is doubled." + }, + { + "name": "Deadly Shot - Action", + "text": "Make an attack against a _Vulnerable_ target within Far range. On a success, **mark a Stress** to deal **3d4+8** physical damage." + } + ], + "hp": "3", + "motives_and_tactics": "Perforate distracted targets, play dead, steal skin", + "name": "Skeleton Archer", + "range": "Far", + "stress": "2", + "thresholds": "4/7", + "tier": "1", + "type": "Ranged" + }, + { + "atk": "-1", + "attack": "Bone Claws", + "damage": "1 phy", + "description": "A clattering pile of bones.", + "difficulty": "8", + "feature": [ + { + "name": "Minion (4) - Passive", + "text": "The Dredge is defeated when they take any damage. For every 4 damage a PC deals to the Dredge, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Dredges within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 1 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Fall apart, overwhelm, play dead, steal skin", + "name": "Skeleton Dredge", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+2", + "attack": "Rusty Greatsword", + "damage": "1d10+2 phy", + "description": "A large armored skeleton with a huge blade.", + "difficulty": "13", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Knight makes a successful attack, all PCs within Close range lose a Hope and you gain a Fear." + }, + { + "name": "Cut to the Bone - Action", + "text": "**Mark a Stress** to make an attack against all targets within Very Close range. Targets the Knight succeeds against take **1d8+2** physical damage and must mark a Stress." + }, + { + "name": "Dig Two Graves - Reaction", + "text": "When the Knight is defeated, they make an attack against a target within Very Close range (prioritizing the creature who killed them). On a success, the target takes **1d4+8** physical damage and loses **1d4** Hope." + } + ], + "hp": "5", + "motives_and_tactics": "Cut down the living, steal skin, wreak havoc", + "name": "Skeleton Knight", + "range": "Melee", + "stress": "2", + "thresholds": "7/13", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+0", + "attack": "Sword", + "damage": "1d6+2 phy", + "description": "A dirt-covered skeleton armed with a rusted blade.", + "difficulty": "10", + "feature": [ + { + "name": "Only Bones - Passive", + "text": "The Warrior is resistant to physical damage." + }, + { + "name": "Won't Stay Dead - Reaction", + "text": "When the Warrior is defeated, you can spotlight them and roll a **d6**. On a result of 6, if there are other adversaries on the battlefield, the Warrior re-forms with no marked HP." + } + ], + "hp": "3", + "motives_and_tactics": "Feign death, gang up, steal skin", + "name": "Skeleton Warrior", + "range": "Melee", + "stress": "2", + "thresholds": "4/8", + "tier": "1", + "type": "Standard" + }, + { + "atk": "+3", + "attack": "Empowered Longsword", + "damage": "1d8+4 phy/mag", + "description": "A mercenary combining swordplay and magic to deadly effect.", + "difficulty": "14", + "experience": "Magical Knowledge +2", + "feature": [ + { + "name": "Arcane Steel - Passive", + "text": "Damage dealt by the Spellblade's standard attack is considered both physical and magic." + }, + { + "name": "Suppressing Blast - Action", + "text": "**Mark a Stress** and target a group within Far range. All targets must succeed on an Agility Reaction Roll or take **1d8+2** magic damage. You gain a Fear for each target who marked HP from this attack." + }, + { + "name": "Move as a Unit - Action", + "text": "**Spend 2 Fear** to spotlight up to five allies within Far range." + }, + { + "name": "Momentum - Reaction", + "text": "When the Spellblade makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Blast, command, endure", + "name": "Spellblade", + "range": "Melee", + "stress": "3", + "thresholds": "8/14", + "tier": "1", + "type": "Leader" + }, + { + "atk": "-3", + "attack": "Claws", + "damage": "1d8+2 phy", + "description": "A skittering mass of ordinary rodents moving as one like a ravenous wave.", + "difficulty": "10", + "feature": [ + { + "name": "Horde (1d4+1) - Passive", + "text": "When the Swarm has marked half or more of their HP, their standard attack deals **1d4+1** physical damage instead." + }, + { + "name": "In Your Face - Passive", + "text": "All targets within Melee range have disadvantage on attacks against targets other than the Swarm." + } + ], + "hp": "6", + "motives_and_tactics": "Consume, obscure, swarm", + "name": "Swarm of Rats", + "range": "Melee", + "stress": "2", + "thresholds": "6/10", + "tier": "1", + "type": "Horde (/HP)" + }, + { + "atk": "+0", + "attack": "Scythe", + "damage": "1d8+1 phy", + "description": "A faerie warrior adorned in armor made of leaves and bark.", + "difficulty": "11", + "experience": "Tracker +2", + "feature": [ + { + "name": "Pack Tactics - Passive", + "text": "If the Soldier makes a standard attack and another Sylvan Soldier is within Melee range of the target, deal **1d8+5** physical damage instead of their standard damage." + }, + { + "name": "Forest Control - Action", + "text": "**Spend a Fear** to pull down a tree within Close range. A creature hit by the tree must succeed on an Agility Reaction Roll (15) or take **1d10** physical damage." + }, + { + "name": "Blend In - Reaction", + "text": "When the Soldier makes a successful attack, you can **mark a Stress** to become _Hidden_ until the Soldier's next attack or a PC succeeds on an Instinct Roll (14) to find them." + } + ], + "hp": "4", + "motives_and_tactics": "Ambush, hide, overwhelm, protect, trail", + "name": "Sylvan Soldier", + "range": "Melee", + "stress": "2", + "thresholds": "6/11", + "tier": "1", + "type": "Standard" + }, + { + "atk": "+0", + "attack": "Thorns", + "damage": "1d6+3 phy", + "description": "A cluster of animate, blood-drinking tumbleweeds, each the size of a large gourd.", + "difficulty": "12", + "experience": "Camouflage +2", + "feature": [ + { + "name": "Horde (1d4+2) - Passive", + "text": "When the Swarm has marked half or more of their HP, their standard attack deals **1d4+2** physical damage instead." + }, + { + "name": "Crush - Action", + "text": "**Mark a Stress** to deal **2d6+8** direct physical damage to a target with 3 or more bramble tokens." + }, + { + "name": "Encumber - Reaction", + "text": "When the Swarm succeeds on an attack, give the target a bramble token. If a target has any bramble tokens, they are _Restrained_. If a target has 3 or more bramble tokens, they are also _Vulnerable_. All bramble tokens can be removed by succeeding on a Finesse Roll (12 + the number of bramble tokens) or dealing Major or greater damage to the Swarm. If bramble tokens are removed from a target using a Finesse Roll, a number of Tangle Bramble Minions spawn within Melee range equal to the number of tokens removed." + } + ], + "hp": "6", + "motives_and_tactics": "Digest, entangle, immobilize", + "name": "Tangle Bramble Swarm", + "range": "Melee", + "stress": "3", + "thresholds": "6/11", + "tier": "1", + "type": "Horde (3/HP)" + }, + { + "atk": "-1", + "attack": "Thorns", + "damage": "2 phy", + "description": "An animate, blood-drinking tumbleweed.", + "difficulty": "11", + "feature": [ + { + "name": "Minion (4) - Passive", + "text": "The Bramble is defeated when they take any damage. For every 4 damage a PC deals to the Tangle Bramble, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Tangle Brambles within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage." + }, + { + "name": "Drain and Multiply - Reaction", + "text": "When an attack from the Bramble causes a target to mark HP and there are three or more Tangle Bramble Minions within Close range, you can combine the Minions into a Tangle Bramble Swarm Horde. The Horde's HP is equal to the number of Minions combined." + } + ], + "hp": "1", + "motives_and_tactics": "Combine, drain, entangle", + "name": "Tangle Bramble", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+2", + "attack": "Claymore", + "damage": "1d12+2 phy", + "description": "A master-at-arms wielding a sword twice their size.", + "difficulty": "14", + "feature": [ + { + "name": "Goading Strike - Action", + "text": "Make a standard attack against a target. On a success, **mark a Stress** to _Taunt_ the target until their next successful attack. The next time the _Taunted_ target attacks, they have disadvantage against targets other than the Weaponmaster." + }, + { + "name": "Adrenaline Burst - Action", + "text": "Once per scene, **spend a Fear** to clear 2 HP and 2 Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Weaponmaster makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Act first, aim for the weakest, intimidate", + "name": "Weaponmaster", + "range": "Very Close", + "stress": "3", + "thresholds": "8/15", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+0", + "attack": "Scythe", + "damage": "1d8+5 phy", + "description": "An imperious tree-person leading their forest's defenses.", + "difficulty": "11", + "experience": "Leadership +3", + "feature": [ + { + "name": "Voice of the Forest - Action", + "text": "**Mark a Stress** to spotlight **1d4** allies within range of a target they can attack without moving. On a success, their attacks deal half damage." + }, + { + "name": "Thorny Cage - Action", + "text": "**Spend a Fear** to form a cage around a target within Very Close range and _Restrain_ them until they're freed with a successful Strength Roll. When a creature makes an action roll against the cage, they must mark a Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Dryad makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Command, nurture, prune the unwelcome", + "name": "Young Dryad", + "range": "Melee", + "stress": "2", + "thresholds": "6/11", + "tier": "1", + "type": "Leader" + }, + { + "atk": "+2", + "attack": "Slam", + "damage": "1d12+3 phy", + "description": "A large corpse, decay-bloated and angry.", + "difficulty": "10", + "experience": "Collateral Damage +2, Throw +4", + "feature": [ + { + "name": "Slow - Passive", + "text": "When you spotlight the Zombie and they don't have a token on their stat block, they can't act yet. Place a token on their stat block and describe what they're preparing to do. When you spotlight the Zombie and they have a token on their stat block, clear the token and they can act." + }, + { + "name": "Rend Asunder - Action", + "text": "Make a standard attack with advantage against a target the Zombie has _Restrained_. On a success, the attack deals direct damage." + }, + { + "name": "Rip and Tear - Reaction", + "text": "When the Zombies makes a successful standard attack, you can **mark a Stress** to temporarily _Restrain_ the target and force them to mark 2 Stress." + } + ], + "hp": "7", + "motives_and_tactics": "Crush, destroy, hail debris, slam", + "name": "Brawny Zombie", + "range": "Very Close", + "stress": "4", + "thresholds": "8/15", + "tier": "1", + "type": "Bruiser" + }, + { + "atk": "+4", + "attack": "Too Many Arms", + "damage": "1d20 phy", + "description": "A towering gestalt of corpses moving as one, with torso-sized limbs and fists as large as a grown halfling.", + "difficulty": "13", + "experience": "Intimidation +2, Tear Things Apart +2", + "feature": [ + { + "name": "Destructible - Passive", + "text": "When the Zombie takes Major or greater damage, they mark an additional HP." + }, + { + "name": "Flailing Limbs - Passive", + "text": "When the Zombie makes a standard attack, they can attack all targets within Very Close range." + }, + { + "name": "Another for the Pile - Action", + "text": "When the Zombie is within Very Close range of a corpse, they can incorporate it into themselves, clearing a HP and a Stress." + }, + { + "name": "Tormented Screams - Action", + "text": "**Mark a Stress** to cause all PCs within Far range to make a Presence Reaction Roll (13). Targets who fail lose a Hope and you gain a Fear for each. Targets who succeed must mark a Stress." + } + ], + "hp": "10", + "motives_and_tactics": "Absorb corpses, flail, hunger, terrify", + "name": "Patchwork Zombie Hulk", + "range": "Very Close", + "stress": "3", + "thresholds": "8/15", + "tier": "1", + "type": "Solo" + }, + { + "atk": "-3", + "attack": "Bite", + "damage": "2 phy", + "description": "A decaying corpse ambling toward their prey.", + "difficulty": "8", + "feature": [ + { + "name": "Minion (3) - Passive", + "text": "The Zombie is defeated when they take any damage. For every 3 damage a PC deals to the Zombie, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Rotted Zombies within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Eat flesh, hunger, maul, surround", + "name": "Rotted Zombie", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "1", + "type": "Minion" + }, + { + "atk": "+0", + "attack": "Bite", + "damage": "1d6+1 phy", + "description": "An animated corpse that moves shakily, driven only by hunger.", + "difficulty": "10", + "feature": [ + { + "name": "Too Many to Handle - Passive", + "text": "When the Zombie is within Melee range of a creature and at least one other Zombie is within Close range, all attacks against that creature have advantage." + }, + { + "name": "Horrifying - Passive", + "text": "Targets who mark HP from the Zombie's attacks must also mark a Stress." + } + ], + "hp": "4", + "motives_and_tactics": "Devour, hungry, mob enemy, shred flesh", + "name": "Shambling Zombie", + "range": "Melee", + "stress": "1", + "thresholds": "4/6", + "tier": "1", + "type": "Standard" + }, + { + "atk": "-1", + "attack": "Bite", + "damage": "1d10+2 phy", + "description": "A group of shambling corpses instinctively moving together.", + "difficulty": "8", + "feature": [ + { + "name": "Horde (1d4+2) - Passive", + "text": "When the Zombies have marked half or more of their HP, their standard attack deals **1d4+2** physical damage instead." + }, + { + "name": "Overwhelm - Reaction", + "text": "When the Zombies mark HP from an attack within Melee range, you can **mark a Stress** to make a standard attack against the attacker." + } + ], + "hp": "6", + "motives_and_tactics": "Consume flesh, hunger, maul", + "name": "Zombie Pack", + "range": "Melee", + "stress": "3", + "thresholds": "6/12", + "tier": "1", + "type": "Horde (2/HP)" + }, + { + "atk": "+0", + "attack": "Longbow", + "damage": "2d6+3 phy", + "description": "A group of trained archers bearing massive bows.", + "difficulty": "13", + "feature": [ + { + "name": "Horde (1d6+3) - Passive", + "text": "When the Squadron has marked half or more of their HP, their standard attack deals **1d6+3** physical damage instead." + }, + { + "name": "Focused Volley - Action", + "text": "**Spend a Fear** to target a point within Far range. Make an attack with advantage against all targets within Close range of that point. Targets the Squadron succeeds against take **1d10+4** physical damage." + }, + { + "name": "Suppressing Fire - Action", + "text": "**Mark a Stress** to target a point within Far range. Until the next roll with Fear, a creature who moves within Close range of that point must make an Agility Reaction Roll. On a failure, they take **2d6+3** physical damage. On a success, they take half damage." + } + ], + "hp": "4", + "motives_and_tactics": "Stick together, survive, volley fire", + "name": "Archer Squadron", + "range": "Far", + "stress": "3", + "thresholds": "8/16", + "tier": "2", + "type": "Horde (2/HP)" + }, + { + "atk": "-1", + "attack": "Thrown Dagger", + "damage": "4 phy", + "description": "A young trainee eager to prove themselves.", + "difficulty": "13", + "experience": "Intrusion +2", + "feature": [ + { + "name": "Minion (6) - Passive", + "text": "The Assassin is defeated when they take any damage. For every 6 damage a PC deals to the Assassin, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Apprentice Assassins within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 4 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Act reckless, kill, prove their worth, show off", + "name": "Apprentice Assassin", + "range": "Very Close", + "stress": "1", + "thresholds": "None", + "tier": "2", + "type": "Minion" + }, + { + "atk": "+3", + "attack": "Poisoned Throwing Dagger", + "damage": "2d8+1 phy", + "description": "A cunning scoundrel skilled in both poisons and ambushing.", + "difficulty": "14", + "experience": "Intrusion +2", + "feature": [ + { + "name": "Grindletooth Venom - Passive", + "text": "Targets who mark HP from the Assassin's attacks are _Vulnerable_ until they clear a HP." + }, + { + "name": "Out of Nowhere - Passive", + "text": "The Assassin has advantage on attacks if they are _Hidden_." + }, + { + "name": "Fumigation - Action", + "text": "Drop a smoke bomb that fills the air within Close range with smoke, _Dizzying_ all targets in this area. _Dizzied_ targets have disadvantage on their next action roll, then clear the condition." + } + ], + "hp": "4", + "motives_and_tactics": "Anticipate, get paid, kill, taint food and water", + "name": "Assassin Poisoner", + "range": "Close", + "stress": "4", + "thresholds": "8/16", + "tier": "2", + "type": "Skulk" + }, + { + "atk": "+5", + "attack": "Serrated Dagger", + "damage": "2d10+2 phy", + "description": "A seasoned killer with a threatening voice and a deadly blade.", + "difficulty": "15", + "experience": "Command +3, Intrusion +3", + "feature": [ + { + "name": "Won't See It Coming - Passive", + "text": "The Assassin deals direct damage while they're _Hidden_." + }, + { + "name": "Strike as One - Action", + "text": "**Mark a Stress** to spotlight a number of other Assassins equal to the Assassin's unmarked Stress." + }, + { + "name": "The Subtle Blade - Reaction", + "text": "When the Assassin successfully makes a standard attack against a _Vulnerable_ target, you can **spend a Fear** to deal Severe damage instead of their standard damage." + }, + { + "name": "Momentum - Reaction", + "text": "When the Assassin makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Ambush, get out alive, kill, prepare for all scenarios", + "name": "Master Assassin", + "range": "Close", + "stress": "5", + "thresholds": "12/25", + "tier": "2", + "type": "Leader" + }, + { + "atk": "+2", + "attack": "Slam", + "damage": "2d6+3 phy", + "description": "A cube-shaped construct with a different rune on each of their six sides.", + "difficulty": "15", + "experience": "Camouflage +2", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Box can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Randomized Tactics - Action", + "text": "**Mark a Stress** and roll a **d6**. The Box uses the corresponding move:\n\n- 1. _Mana Beam._ The Box fires a searing beam. Make an attack against a target within Far range. On a success, deal **2d10+2** magic damage.\n- 2. _Fire Jets._ The Box shoots into the air, spinning and releasing jets of flame. Make an attack against all targets within Close range. Targets the Box succeeds against take **2d8** physical damage.\n- 3. _Trample._ The Box rockets around erratically. Make an attack against all PCs within Close range. Targets the Box succeeds against take **1d6+5** physical damage and are _Vulnerable_ until their next roll with Hope.\n- 4. _Shocking Gas._ The Box sprays out a silver gas sparking with lightning. All targets within Close range must succeed on a Finesse Reaction Roll or mark 3 Stress.\n- 5. _Stunning Clap._ The Box leaps and their sides clap, creating a small sonic boom. All targets within Very Close range must succeed on a Strength Reaction Roll or become _Vulnerable_ until the cube is defeated.\n- 6. _Psionic Whine._ The Box releases a cluster of mechanical bees whose buzz rattles mortal minds. All targets within Close range must succeed on a Presence Reaction Roll or take **2d4+9** direct magic damage." + }, + { + "name": "Overcharge - Reaction", + "text": "Before rolling damage for the Box's attack, you can **mark a Stress** to add a **d6** to the damage roll. Additionally, you gain a Fear." + }, + { + "name": "Death Quake - Reaction", + "text": "When the Box marks their last HP, the magic powering them ruptures in an explosion of force. All targets within Close range must succeed on an Instinct Reaction Roll or take **2d8+1** magic damage." + } + ], + "hp": "8", + "motives_and_tactics": "Change tactics, trample foes, wait in disguise", + "name": "Battle Box", + "range": "Melee", + "stress": "6", + "thresholds": "10/20", + "tier": "2", + "type": "Solo" + }, + { + "atk": "+2", + "attack": "Energy Blast", + "damage": "2d8+3 mag", + "description": "A floating humanoid skull animated by scintillating magic.", + "difficulty": "15", + "feature": [ + { + "name": "Levitation - Passive", + "text": "The Skull levitates several feet off the ground and can't be _Restrained_." + }, + { + "name": "Wards - Passive", + "text": "The Skull is resistant to magic damage." + }, + { + "name": "Magic Burst - Action", + "text": "**Mark a Stress** to make an attack against all targets within Close range. Targets the Skull succeeds against take **2d6+4** magic damage." + }, + { + "name": "Siphon Magic - Action", + "text": "**Spend a Fear** to make an attack against a PC with a Spellcast trait within Very Close range. On a success, the target marks **1d4** Stress and the Skull clears that many Stress. Additionally, on a success, the Skull can immediately be spotlighted again." + } + ], + "hp": "5", + "motives_and_tactics": "Cackle, consume magic, serve creator", + "name": "Chaos Skull", + "range": "Close", + "stress": "4", + "thresholds": "8/16", + "tier": "2", + "type": "Ranged" + }, + { + "atk": "+0", + "attack": "Spears", + "damage": "6 phy", + "description": "A poorly trained civilian pressed into war.", + "difficulty": "12", + "feature": [ + { + "name": "Minion (6) - Passive", + "text": "The Conscript is defeated when they take any damage. For every 6 damage a PC deals to the Conscript, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Conscripts within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 6 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Follow orders, gang up, survive", + "name": "Conscript", + "range": "Very Close", + "stress": "1", + "thresholds": "None", + "tier": "2", + "type": "Minion" + }, + { + "atk": "-3", + "attack": "Dagger", + "damage": "1d4+3 phy", + "description": "An accomplished manipulator and master of the social arts.", + "difficulty": "13", + "experience": "Manipulation +3, Socialite +3", + "feature": [ + { + "name": "Searing Glance - Reaction", + "text": "When a PC within Close range makes a Presence Roll, you can **mark a Stress** to cast a gaze toward the aftermath. On the target's failure, they must mark 2 Stress and are _Vulnerable_ until the scene ends or they succeed on a social action against the Courtesan. On the target's success, they must mark a Stress." + } + ], + "hp": "3", + "motives_and_tactics": "Entice, maneuver, secure patrons", + "name": "Courtesan", + "range": "Melee", + "stress": "4", + "thresholds": "7/13", + "tier": "2", + "type": "Social" + }, + { + "atk": "+2", + "attack": "Rune-Covered Rod", + "damage": "2d4+3 mag", + "description": "An experienced mage wielding shadow and fear.", + "difficulty": "14", + "experience": "Fallen Lore +2, Rituals +2", + "feature": [ + { + "name": "Enervating Blast - Action", + "text": "**Spend a Fear** to make a standard attack against a target within range. On a success, the target must mark a Stress." + }, + { + "name": "Shroud of the Fallen - Action", + "text": "**Mark a Stress** to wrap an ally within Close range in a shroud of _Protection_ until the Adept marks their last HP. While _Protected_, the target has resistance to all damage." + }, + { + "name": "Shadow Shackles - Action", + "text": "**Spend a Fear** and choose a point within Far range. All targets within Close range of that point are _Restrained_ in smoky chains until they break free with a successful Strength or Instinct Roll. A target _Restrained_ by this feature must spend a Hope to make an action roll." + }, + { + "name": "Fear Is Fuel - Reaction", + "text": "Twice per scene, when a PC rolls a failure with Fear, clear a Stress." + } + ], + "hp": "4", + "motives_and_tactics": "Curry favor, hinder foes, uncover knowledge", + "name": "Cult Adept", + "range": "Far", + "stress": "6", + "thresholds": "9/18", + "tier": "2", + "type": "Support" + }, + { + "atk": "+2", + "attack": "Long Knife", + "damage": "2d8+4 phy", + "description": "A professional killer-turned-cultist.", + "difficulty": "15", + "feature": [ + { + "name": "Shadow's Embrace - Passive", + "text": "The Fang can climb and walk on vertical surfaces. **Mark a Stress** to move from one shadow to another within Far range." + }, + { + "name": "Pick Off the Straggler - Action", + "text": "**Mark a Stress** to cause a target within Melee range to make an Instinct Reaction Roll. On a failure, the target must mark 2 Stress and is teleported with the Fang to a shadow within Far range, making them temporarily _Vulnerable_. On a success, the target must mark a Stress." + } + ], + "hp": "4", + "motives_and_tactics": "Capture sacrifices, isolate prey, rise in the ranks", + "name": "Cult Fang", + "range": "Melee", + "stress": "4", + "thresholds": "9/17", + "tier": "2", + "type": "Skulk" + }, + { + "atk": "+0", + "attack": "Ritual Dagger", + "damage": "5 phy", + "description": "A low-ranking cultist in simple robes, eager to gain power.", + "difficulty": "13", + "feature": [ + { + "name": "Minion (6) - Passive", + "text": "The Initiate is defeated when they take any damage. For every 6 damage a PC deals to the Initiate, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Cult Initiates within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Follow orders, gain power, seek forbidden knowledge", + "name": "Cult Initiate", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "2", + "type": "Minion" + }, + { + "atk": "+0", + "attack": "Claws and Fangs", + "damage": "2d8+2 phy", + "description": "Unnatural hounds lit from within by hellfire.", + "difficulty": "15", + "experience": "Scent Tracking +3", + "feature": [ + { + "name": "Horde (2d4+1) - Passive", + "text": "When the Pack has marked half or more of their HP, their standard attack deals **2d4+1** physical damage instead." + }, + { + "name": "Dreadhowl - Action", + "text": "**Mark a Stress** to make all targets within Very Close range lose a Hope. If a target is not able to lose a Hope, they must instead mark 2 Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Pack makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Cause fear, consume flesh, please masters", + "name": "Demonic Hound Pack", + "range": "Melee", + "stress": "3", + "thresholds": "11/23", + "tier": "2", + "type": "Horde (1/HP)" + }, + { + "atk": "+0", + "attack": "Shocking Bite", + "damage": "2d6+4 phy", + "description": "A swarm of eels that encircle and electrocute.", + "difficulty": "14", + "feature": [ + { + "name": "Horde (2d4+1) - Passive", + "text": "When the Eels have marked half or more of their HP, their standard attack deals **2d4+1** physical damage instead." + }, + { + "name": "Paralyzing Shock - Action", + "text": "**Mark a Stress** to make a standard attack against all targets within Very Close range. You gain a Fear for each target that marks HP." + } + ], + "hp": "5", + "motives_and_tactics": "Avoid larger predators, shock prey, tear apart", + "name": "Electric Eels", + "range": "Melee", + "stress": "3", + "thresholds": "10/20", + "tier": "2", + "type": "Horde (/HP)" + }, + { + "atk": "+1", + "attack": "Spear", + "damage": "2d8+4 phy", + "description": "An armored squire or experienced commoner looking to advance.", + "difficulty": "15", + "feature": [ + { + "name": "Reinforce - Action", + "text": "**Mark a Stress** to move into Melee range of an ally and make a standard attack against a target within Very Close range. On a success, deal **2d10+2** physical damage and the ally can clear a Stress." + }, + { + "name": "Vassal's Loyalty - Reaction", + "text": "When the Soldier is within Very Close range of a knight or other noble who would take damage, you can **mark a Stress** to move into Melee range of them and take the damage instead." + } + ], + "hp": "4", + "motives_and_tactics": "Gain glory, keep order, make alliances", + "name": "Elite Soldier", + "range": "Very Close", + "stress": "3", + "thresholds": "9/18", + "tier": "2", + "type": "Standard" + }, + { + "atk": "+1", + "attack": "Bite and Claw", + "damage": "2d6+5 phy", + "description": "A magical necromantic experiment gone wrong, leaving them warped and ungainly.", + "difficulty": "13", + "experience": "Copycat +3", + "feature": [ + { + "name": "Warped Fortitude - Passive", + "text": "The Experiment is resistant to physical damage." + }, + { + "name": "Overwhelm - Passive", + "text": "When a target the Experiment attacks has other adversaries within Very Close range, the Experiment deals double damage." + }, + { + "name": "Lurching Lunge - Action", + "text": "**Mark a Stress** to spotlight the Experiment as an additional GM move instead of spending Fear." + } + ], + "hp": "3", + "motives_and_tactics": "Devour, hunt, track", + "name": "Failed Experiment", + "range": "Melee", + "stress": "3", + "thresholds": "12/23", + "tier": "2", + "type": "Standard" + }, + { + "atk": "+2", + "attack": "Longbow", + "damage": "2d8+4 phy", + "description": "A leather-clad warrior bearing a whip and massive bow.", + "difficulty": "16", + "experience": "Animal Handling +3", + "feature": [ + { + "name": "Two as One - Passive", + "text": "When the Beastmaster is spotlighted, you can also spotlight a Tier 1 animal adversary currently under their control." + }, + { + "name": "Pinning Strike - Action", + "text": "Make a standard attack against a target. On a success, you can **mark a Stress** to pin them to a nearby surface. The pinned target is _Restrained_ until they break free with a successful Finesse or Strength Roll." + }, + { + "name": "Deadly Companion - Action", + "text": "Twice per scene, summon a Bear, Dire Wolf, or similar Tier 1 animal adversary under the Beastmaster's control. The adversary appears at Close range and is immediately spotlighted." + } + ], + "hp": "6", + "motives_and_tactics": "Command, make a living, maneuver, pin down, protect companion animals", + "name": "Giant Beastmaster", + "range": "Far", + "stress": "5", + "thresholds": "12/24", + "tier": "2", + "type": "Leader" + }, + { + "atk": "+2", + "attack": "Warhammer", + "damage": "2d12+3 phy", + "description": "An especially muscular giant wielding a warhammer larger than a human.", + "difficulty": "15", + "experience": "Intrusion +2", + "feature": [ + { + "name": "Battering Ram - Action", + "text": "**Mark a Stress** to have the Brawler charge at an inanimate object within Close range they could feasibly smash (such as a wall, cart, or market stand) and destroy it. All targets within Very Close range of the object must succeed on an Agility Reaction Roll or take **2d4+3** physical damage from the shrapnel." + }, + { + "name": "Bloody Reprisal - Reaction", + "text": "When the Brawler marks 2 or more HP from an attack within Very Close range, you can make a standard attack against the attacker. On a success, the Brawler deals **2d6+15** physical damage instead of their standard damage." + }, + { + "name": "Momentum - Reaction", + "text": "When the Brawler makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Make a living, overwhelm, slam, topple", + "name": "Giant Brawler", + "range": "Very Close", + "stress": "4", + "thresholds": "14/28", + "tier": "2", + "type": "Bruiser" + }, + { + "atk": "+1", + "attack": "Warhammer", + "damage": "5 phy", + "description": "A giant fighter wearing borrowed armor.", + "difficulty": "13", + "feature": [ + { + "name": "Minion (7) - Passive", + "text": "The Recruit is defeated when they take any damage. For every 7 damage a PC deals to the Recruit, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Giant Recruits within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Batter, make a living, overwhelm, terrify", + "name": "Giant Recruit", + "range": "Very Close", + "stress": "2", + "thresholds": "None", + "tier": "2", + "type": "Minion" + }, + { + "atk": "+1", + "attack": "Claws and Beak", + "damage": "2d6+3 phy", + "description": "A giant bird of prey with blood-stained talons.", + "difficulty": "14", + "feature": [ + { + "name": "Flight - Passive", + "text": "While flying, the Eagle gains a +3 bonus to their Difficulty." + }, + { + "name": "Deadly Dive - Action", + "text": "**Mark a Stress** to attack a target within Far range. On a success, deal **2d10+2** physical damage and knock the target over, making them _Vulnerable_ until they next act." + }, + { + "name": "Take Off- Action", + "text": "Make an attack against a target within Very Close range. On a success, deal **2d4+3** physical damage and the target must succeed on an Agility Reaction Roll or become temporarily _Restrained_ within the Eagle's massive talons. If the target is _Restrained_, the Eagle immediately lifts into the air to Very Far range above the battlefield while holding them." + }, + { + "name": "Deadly Drop - Action", + "text": "While flying, the Eagle can drop a _Restrained_ target they are holding. When dropped, the target is no longer _Restrained_ but starts falling. If their fall isn't prevented during the PCs' next action, the target takes **2d20** physical damage when they land." + } + ], + "hp": "4", + "motives_and_tactics": "Hunt prey, stay mobile, strike decisively", + "name": "Giant Eagle", + "range": "Very Close", + "stress": "4", + "thresholds": "8/19", + "tier": "2", + "type": "Skulk" + }, + { + "atk": "+4", + "attack": "Sunsear Shortbow", + "damage": "2d20+3 mag", + "description": "A snake-headed, scaled humanoid with a gilded bow, enraged that their peace has been disturbed.", + "difficulty": "15", + "experience": "Stealth +3", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Gorgon can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Sunsear Arrows - Passive", + "text": "When the Gorgon makes a successful standard attack, the target _Glows_ until the end of the scene and can't become _Hidden_. Attack rolls made against a _Glowing_ target have advantage." + }, + { + "name": "Crown of Serpents - Action", + "text": "Make an attack roll against a target within Melee range using the Gorgon's protective snakes. On a success, **mark a Stress** to deal **2d10+4** physical damage and the target must mark a Stress." + }, + { + "name": "Petrifying Gaze - Reaction", + "text": "When the Gorgon takes damage from an attack within Close range, you can **spend a Fear** to force the attacker to make an Instinct Reaction Roll. On a failure, they begin to turn to stone, marking a HP and starting a Petrification Countdown (4). This countdown ticks down when the Gorgon is attacked. When it triggers, the target must make a death move. If the Gorgon is defeated, all petrification countdowns end." + }, + { + "name": "Momentum - Reaction", + "text": "When the Gorgon makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "9", + "motives_and_tactics": "Corner, hit-and-run, petrify, seek vengeance", + "name": "Gorgon", + "range": "Far", + "stress": "3", + "thresholds": "13/25", + "tier": "2", + "type": "Solo" + }, + { + "atk": "+3", + "attack": "Wing Slash", + "damage": "2d10+4 phy", + "description": "A horse-sized insect with iridescent scales and crystalline wings moving faster than the eye can see.", + "difficulty": "14", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Flickerfly can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Peerless Accuracy - Passive", + "text": "Before the Flickerfly makes an attack, roll a **d6**. On a result of 4 or higher, the target's Evasion is halved against this attack." + }, + { + "name": "Mind Dance - Action", + "text": "**Mark a Stress** to create a magically dazzling display that grapples the minds of nearby foes. All targets within Close range must make an Instinct Reaction Roll. For each target who failed, you gain a Fear and the Flickerfly learns one of the target's fears." + }, + { + "name": "Hallucinatory Breath - Reaction: Countdown (Loop 1d6)", + "text": "When the Flickerfly takes damage for the first time, activate the countdown. When it triggers, the Flickerfly breathes hallucinatory gas on all targets in front of them up to Far range. Targets must succeed on an Instinct Reaction Roll or be tormented by fearful hallucinations. Targets whose fears are known to the Flickerfly have disadvantage on this roll. Targets who fail must mark a Stress and lose a Hope." + } + ], + "hp": "10", + "motives_and_tactics": "Collect shiny things, hunt, swoop", + "name": "Juvenile Flickerfly", + "range": "Very Close", + "stress": "5", + "thresholds": "13/26", + "tier": "2", + "type": "Solo" + }, + { + "atk": "+4", + "attack": "Longsword", + "damage": "2d10+4 phy", + "description": "A decorated soldier with heavy armor and a powerful steed.", + "difficulty": "15", + "experience": "Ancient Knowledge +3, High Society +2, Tactics +2", + "feature": [ + { + "name": "Chevalier - Passive", + "text": "While the Knight is on a mount, they gain a +2 bonus to their Difficulty. When they take Severe damage, they're knocked from their mount and lose this benefit until they're next spotlighted." + }, + { + "name": "Heavily Armored - Passive", + "text": "When the Knight takes physical damage, reduce it by 3." + }, + { + "name": "Cavalry Charge - Action", + "text": "If the Knight is mounted, move up to Far range and make a standard attack against a target. On a success, deal **2d8+4** physical damage and the target must mark a Stress." + }, + { + "name": "For the Realm! - Action", + "text": "**Mark a Stress** to spotlight **1d4+1** allies. Attacks they make while spotlighted in this way deal half damage." + } + ], + "hp": "6", + "motives_and_tactics": "Run down, seek glory, show dominance", + "name": "Knight of the Realm", + "range": "Melee", + "stress": "4", + "thresholds": "13/26", + "tier": "2", + "type": "Leader" + }, + { + "atk": "+3", + "attack": "Backsword", + "damage": "2d8+3 phy", + "description": "A cunning thief with acrobatic skill and a flair for the dramatic.", + "difficulty": "14", + "experience": "Acrobatics +3", + "feature": [ + { + "name": "Quick Hands - Action", + "text": "Make an attack against a target within Melee range. On a success, deal **1d8+2** physical damage and the Thief steals one item or consumable from the target's inventory." + }, + { + "name": "Escape Plan - Action", + "text": "**Mark a Stress** to reveal a snare trap set anywhere on the battlefield by the Thief. All targets within Very Close range of the trap must succeed on an Agility Reaction Roll (13) or be pulled off their feet and suspended upside down. A target is _Restrained_ and _Vulnerable_ until they break free, ending both conditions, with a successful Finesse or Strength Roll (13)." + } + ], + "hp": "4", + "motives_and_tactics": "Evade, hide, pilfer, profit", + "name": "Masked Thief", + "range": "Melee", + "stress": "5", + "thresholds": "8/17", + "tier": "2", + "type": "Skulk" + }, + { + "atk": "-2", + "attack": "Rapier", + "damage": "1d6+2 phy", + "description": "An accomplished merchant with a large operation under their command.", + "difficulty": "15", + "experience": "Nobility +2, Trade +2", + "feature": [ + { + "name": "Everyone Has a Price - Action", + "text": "**Spend a Fear** to offer a target a dangerous bargain for something they want or need. If used on a PC, they must make a Presence Reaction Roll (17). On a failure, they must mark 2 Stress or take the deal." + }, + { + "name": "The Best Muscle Money Can Buy - Action", + "text": "Once per scene, **mark a Stress** to summon **1d4+1** Tier 1 adversaries, who appear at Far range, to enforce the Baron's will." + } + ], + "hp": "5", + "motives_and_tactics": "Abuse power, gather resources, mobilize minions", + "name": "Merchant Baron", + "range": "Melee", + "stress": "3", + "thresholds": "9/19", + "tier": "2", + "type": "Social" + }, + { + "atk": "+2", + "attack": "Battleaxe", + "damage": "2d8+5 phy", + "description": "A massive bull-headed firbolg with a quick temper.", + "difficulty": "16", + "experience": "Navigation +2", + "feature": [ + { + "name": "Ramp Up - Passive", + "text": "You must **spend a Fear** to spotlight the Minotaur. While spotlighted, they can make their standard attack against all targets within range." + }, + { + "name": "Charging Bull - Action", + "text": "**Mark a Stress** to charge through a group within Close range and make an attack against all targets in the Minotaur's path. Targets the Minotaur succeeds against take **2d6+8** physical damage and are knocked back to Very Far range. If a target is knocked into a solid object or another creature, they take an extra **1d6** damage (combine the damage)." + }, + { + "name": "Gore - Action", + "text": "Make an attack against a target within Very Close range, moving the Minotaur into Melee range of them. On a success, deal **2d8** direct physical damage." + } + ], + "hp": "7", + "motives_and_tactics": "Consume, gore, navigate, overpower, pursue", + "name": "Minotaur Wrecker", + "range": "Very Close", + "stress": "5", + "thresholds": "14/27", + "tier": "2", + "type": "Bruiser" + }, + { + "atk": "+5", + "attack": "Tear at Flesh", + "damage": "2d12+1 phy", + "description": "An undead figure wearing a heavy leather coat, with searching eyes and a casually cruel demeanor.", + "difficulty": "16", + "experience": "Bloodhound +3", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Hunter makes a successful attack, all PCs within Far range lose a Hope and you gain a Fear." + }, + { + "name": "Deathlock - Action", + "text": "**Spend a Fear** to curse a target within Very Close range with a necrotic _Deathlock_ until the end of the scene. Attacks made by the Hunter against a _Deathlocked_ target deal direct damage. The Hunter can only maintain one _Deathlock_ at a time." + }, + { + "name": "Inevitable Death - Action", + "text": "**Mark a Stress** to spotlight **1d4** allies. Attacks they make while spotlighted in this way deal half damage." + }, + { + "name": "Rampage - Reaction: Countdown (Loop 1d6)", + "text": "When the Hunter is in the spotlight for the first time, activate the countdown. When it triggers, move the Hunter in a straight line to a point within Far range and make an attack against all targets in their path. Targets the Hunter succeeds against take **2d8+2** physical damage." + } + ], + "hp": "6", + "motives_and_tactics": "Devour, hunt, track", + "name": "Mortal Hunter", + "range": "Very Close", + "stress": "4", + "thresholds": "15/27", + "tier": "2", + "type": "Leader" + }, + { + "atk": "-3", + "attack": "Wand", + "damage": "1d4+3 phy", + "description": "A high-ranking courtier with the ear of the local nobility.", + "difficulty": "14", + "experience": "Administration +3, Courtier +3", + "feature": [ + { + "name": "Devastating Retort - Passive", + "text": "A PC who rolls less than 17 on an action roll targeting the Advisor must mark a Stress." + }, + { + "name": "Bend Ears - Action", + "text": "**Mark a Stress** to influence an NPC within Melee range with whispered words. That target's opinion on one matter shifts toward the Advisor's preference unless it is in direct opposition to the target's motives." + }, + { + "name": "Scapegoat - Action", + "text": "**Spend a Fear** to convince a crowd or notable individual that one person or group is responsible for some problem facing the target. The target becomes hostile to the scapegoat until convinced of their innocence with a successful Presence Roll (17)." + } + ], + "hp": "3", + "motives_and_tactics": "Curry favor, manufacture evidence, scheme", + "name": "Royal Advisor", + "range": "Far", + "stress": "3", + "thresholds": "8/15", + "tier": "2", + "type": "Social" + }, + { + "atk": "+3", + "attack": "Sigil-laden Staff", + "damage": "2d12 mag", + "description": "A clandestine leader with a direct channel to the Fallen Gods.", + "difficulty": "16", + "experience": "Coercion +2, Fallen Lore +2", + "feature": [ + { + "name": "Seize Your Moment - Action", + "text": "**Spend 2 Fear** to spotlight **1d4** allies. Attacks they make while spotlighted in this way deal half damage." + }, + { + "name": "Our Master's Will - Reaction", + "text": "When you spotlight an ally within Far range, **mark a Stress** to gain a Fear." + }, + { + "name": "Summoning Ritual - Reaction: Countdown (6)", + "text": "When the Secret-Keeper is in the spotlight for the first time, activate the countdown. When they mark HP, tick down this countdown by the number of HP marked. When it triggers, summon a Minor Demon who appears at Close range." + }, + { + "name": "Fallen Hounds - Reaction", + "text": "Once per scene, when the Secret-Keeper marks 2 or more HP, you can **mark a Stress** to summon a Demonic Hound Pack, which appears at Close range and is immediately spotlighted." + } + ], + "hp": "7", + "motives_and_tactics": "Amass great power, plot, take command", + "name": "Secret-Keeper", + "range": "Far", + "stress": "4", + "thresholds": "13/26", + "tier": "2", + "type": "Leader" + }, + { + "atk": "+2", + "attack": "Toothy Maw", + "damage": "2d12+1 phy", + "description": "A large aquatic predator, always on the move.", + "difficulty": "14", + "experience": "Sense of Smell +3", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Shark makes a successful attack, all PCs within Far range lose a Hope and you gain a Fear." + }, + { + "name": "Rending Bite - Passive", + "text": "When the Shark makes a successful attack, the target must mark an Armor Slot without receiving its benefits (they can still use armor to reduce the damage). If they can't mark an Armor Slot, they must mark an additional HP." + }, + { + "name": "Blood in the Water - Reaction", + "text": "When a creature within Close range of the Shark marks HP from another creature's attack, you can **mark a Stress** to immediately spotlight the Shark, moving them into Melee range of the target and making a standard attack." + } + ], + "hp": "7", + "motives_and_tactics": "Find the blood, isolate prey, target the weak", + "name": "Shark", + "range": "Very Close", + "stress": "3", + "thresholds": "14/28", + "tier": "2", + "type": "Bruiser" + }, + { + "atk": "+2", + "attack": "Distended Jaw Bite", + "damage": "2d6+3 phy", + "description": "A half-fish person with shimmering scales and an irresistible voice.", + "difficulty": "14", + "experience": "Song Repertoire +3", + "feature": [ + { + "name": "Captive Audience - Passive", + "text": "If the Siren makes a standard attack against a target _Entranced_ by their song, the attack deals **2d10+1** damage instead of their standard damage." + }, + { + "name": "Enchanting Song - Action", + "text": "**Spend a Fear** to sing a song that affects all targets within Close range. Targets must succeed on an Instinct Reaction Roll or become _Entranced_ until they mark 2 Stress. Other Sirens within Close range of the target can **mark a Stress** to each add a +1 bonus to the Difficulty of the reaction roll. While _Entranced_, a target can't act and is _Vulnerable_." + } + ], + "hp": "5", + "motives_and_tactics": "Consume, lure prey, subdue with song", + "name": "Siren", + "range": "Melee", + "stress": "3", + "thresholds": "9/18", + "tier": "2", + "type": "Skulk" + }, + { + "atk": "+3", + "attack": "Longbow", + "damage": "2d10+2 phy", + "description": "A ghostly fighter with an ethereal bow, unable to move on while their charge is vulnerable.", + "difficulty": "13", + "experience": "Ancient Knowledge +2", + "feature": [ + { + "name": "Ghost - Passive", + "text": "The Archer has resistance to physical damage. **Mark a Stress** to move up to Close range through solid objects." + }, + { + "name": "Pick Your Target - Action", + "text": "**Spend a Fear** to make an attack within Far range against a PC who is within Very Close range of at least two other PCs. On a success, the target takes **2d8+12** physical damage." + } + ], + "hp": "3", + "motives_and_tactics": "Move through solid objects, stay out of the fray, rehash old battles", + "name": "Spectral Archer", + "range": "Far", + "stress": "3", + "thresholds": "6/14", + "tier": "2", + "type": "Ranged" + }, + { + "atk": "+3", + "attack": "Longbow", + "damage": "2d10+3 phy", + "description": "A ghostly commander leading their troops beyond death.", + "difficulty": "16", + "experience": "Ancient Knowledge +3", + "feature": [ + { + "name": "Ghost - Passive", + "text": "The Captain has resistance to physical damage. **Mark a Stress** to move up to Close range through solid objects." + }, + { + "name": "Unending Battle - Action", + "text": "**Spend 2 Fear** to return up to **1d4+1** defeated Spectral allies to the battle at the points where they first appeared (with no marked HP or Stress)." + }, + { + "name": "Hold Fast - Reaction", + "text": "When the Captain's Spectral allies are forced to make a reaction roll, you can **mark a Stress** to give those allies a +2 bonus to the roll." + }, + { + "name": "Momentum - Reaction", + "text": "When the Captain makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Move through solid objects, rally troops, rehash old battles", + "name": "Spectral Captain", + "range": "Far", + "stress": "4", + "thresholds": "13/26", + "tier": "2", + "type": "Leader" + }, + { + "atk": "+1", + "attack": "Spear", + "damage": "2d8+1 phy", + "description": "A ghostly fighter with spears and swords, anchored by duty.", + "difficulty": "15", + "experience": "Ancient Knowledge +2", + "feature": [ + { + "name": "Ghost - Passive", + "text": "The Guardian has resistance to physical damage. **Mark a Stress** to move up to Close range through solid objects." + }, + { + "name": "Grave Blade - Action", + "text": "**Spend a Fear** to make an attack against a target within Very Close range. On a success, deal **2d10+6** physical damage and the target must mark a Stress." + } + ], + "hp": "4", + "motives_and_tactics": "Move through solid objects, protect treasure, rehash old battles", + "name": "Spectral Guardian", + "range": "Very Close", + "stress": "3", + "thresholds": "7/15", + "tier": "2", + "type": "Standard" + }, + { + "atk": "-2", + "attack": "Dagger", + "damage": "2d6+3 phy", + "description": "A skilled espionage agent with a knack for being in the right place to overhear secrets.", + "difficulty": "15", + "experience": "Espionage +3", + "feature": [ + { + "name": "Gathering Secrets - Action", + "text": "**Spend a Fear** to describe how the Spy knows a secret about a PC in the scene." + }, + { + "name": "Fly on the Wall - Reaction", + "text": "When a PC or group is discussing something sensitive, you can **mark a Stress** to reveal that the Spy is present in the scene, observing them. If the Spy escapes the scene to report their findings, you gain **1d4** Fear." + } + ], + "hp": "4", + "motives_and_tactics": "Cut and run, disguise appearance, eavesdrop", + "name": "Spy", + "range": "Melee", + "stress": "3", + "thresholds": "8/17", + "tier": "2", + "type": "Social" + }, + { + "atk": "+3", + "attack": "Bite and Claws", + "damage": "2d8+6 phy", + "description": "A prowling hunter, like a slinking mountain lion, with a slate-gray stone body.", + "difficulty": "13", + "experience": "Stonesense +3", + "feature": [ + { + "name": "Stonestrider - Passive", + "text": "The Stonewraith can move through stone and earth as easily as air. While within stone or earth, they are _Hidden_ and immune to all damage." + }, + { + "name": "Rocky Ambush - Action", + "text": "While _Hidden_, **mark a Stress** to leap into Melee range with a target within Very Close range. The target must succeed on an Agility or Instinct Reaction Roll (15) or take **2d8** physical damage and become temporarily _Restrained_." + }, + { + "name": "Avalanche Roar - Action", + "text": "**Spend a Fear** to roar while within a cave and cause a cave-in. All targets within Close range must succeed on an Agility Reaction Roll (14) or take **2d10** physical damage. The rubble can be cleared with a Progress Countdown (8)." + }, + { + "name": "Momentum - Reaction", + "text": "When the Stonewraith makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Defend territory, isolate prey, stalk", + "name": "Stonewraith", + "range": "Melee", + "stress": "3", + "thresholds": "11/22", + "tier": "2", + "type": "Skulk" + }, + { + "atk": "+4", + "attack": "Staff", + "damage": "2d10+4 mag", + "description": "A battle-hardened mage trained in destructive magic.", + "difficulty": "16", + "experience": "Magical Knowledge +2, Strategize +2", + "feature": [ + { + "name": "Battle Teleport - Passive", + "text": "Before or after making a standard attack, you can **mark a Stress** to teleport to a location within Far range." + }, + { + "name": "Refresh Warding Sphere - Action", + "text": "**Mark a Stress** to refresh the Wizard's \"Warding Sphere\" reaction." + }, + { + "name": "Eruption - Action", + "text": "**Spend a Fear** and choose a point within Far range. A Very Close area around that point erupts into impassable terrain. All targets within that area must make an Agility Reaction Roll (14). Targets who fail take **2d10** physical damage and are thrown out of the area. Targets who succeed take half damage and aren't moved." + }, + { + "name": "Arcane Artillery - Action", + "text": "**Spend a Fear** to unleash a precise hail of magical blasts. All targets in the scene must make an Agility Reaction Roll. Targets who fail take **2d12** magic damage. Targets who succeed take half damage." + }, + { + "name": "Warding Sphere - Reaction", + "text": "When the Wizard takes damage from an attack within Close range, deal **2d6** magic damage to the attacker. This reaction can't be used again until the Wizard refreshes it with their \"Refresh Warding Sphere\" action." + } + ], + "hp": "5", + "motives_and_tactics": "Develop new spells, seek power, shatter formations", + "name": "War Wizard", + "range": "Far", + "stress": "6", + "thresholds": "11/23", + "tier": "2", + "type": "Ranged" + }, + { + "atk": "+3", + "attack": "Wing Slash", + "damage": "3d20 phy", + "description": "A winged insect the size of a large house with iridescent scales and wings that move too fast to track.", + "difficulty": "17", + "feature": [ + { + "name": "Relentless (4) - Passive", + "text": "The Flickerfly can be spotlighted up to four times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Never Misses - Passive", + "text": "When the Flickerfly makes an attack, the target's Evasion is halved against the attack." + }, + { + "name": "Deadly Flight - Passive", + "text": "While flying, the Flickerfly can move up to Far range instead of Close range before taking an action." + }, + { + "name": "Whirlwind - Action", + "text": "**Spend a Fear** to whirl, making an attack against all targets within Very Close range. Targets the Flickerfly succeeds against take **3d8** direct physical damage." + }, + { + "name": "Mind Dance - Action", + "text": "**Mark a Stress** to create a magically dazzling display that grapples the minds of nearby foes. All targets within Close range must make an Instinct Reaction Roll. For each target who failed, you gain a Fear and the Flickerfly learns one of the target's fears." + }, + { + "name": "Hallucinatory Breath - Reaction: Countdown (Loop 1d6)", + "text": "When the Flickerfly takes damage for the first time, activate the countdown. When it triggers, the Flickerfly breathes hallucinatory gas on all targets in front of them up to Far range. Targets must make an Instinct Reaction Roll or be tormented by fearful hallucinations. Targets whose fears are known to the Flickerfly have disadvantage on this roll. Targets who fail lose 2 Hope and take **3d8+3** direct magic damage." + }, + { + "name": "Uncanny Reflexes - Reaction", + "text": "When the Flickerfly takes damage from an attack within Close range, you can **mark a Stress** to take half damage." + } + ], + "hp": "12", + "motives_and_tactics": "Collect shiny things, hunt, nest, swoop", + "name": "Adult Flickerfly", + "range": "Very Close", + "stress": "6", + "thresholds": "20/35", + "tier": "3", + "type": "Solo" + }, + { + "atk": "+2", + "attack": "Hungry Maw", + "damage": "3d6+5 mag", + "description": "A regal cloaked monstrosity with circular horns adorned with treasure.", + "difficulty": "17", + "experience": "Manipulation +3", + "feature": [ + { + "name": "Money Talks - Passive", + "text": "Attacks against the Demon are made with disadvantage unless the attacker spends a handful of gold. This Demon starts with a number of handfuls equal to the number of PCs. When a target marks HP from the Demon's standard attack, they can spend a handful of gold instead of marking HP (1 handful per HP). Add a handful of gold to the Demon for each handful of gold spent by PCs on this feature." + }, + { + "name": "Numbers Must Go Up - Passive", + "text": "Add a bonus to the Demon's attack rolls equal to the number of handfuls of gold they have." + }, + { + "name": "Money Is Time - Action", + "text": "**Spend 3 handfuls of gold (or a Fear)** to spotlight **1d4+1** allies." + } + ], + "hp": "6", + "motives_and_tactics": "Consume, fuel greed, sow dissent", + "name": "Demon of Avarice", + "range": "Melee", + "stress": "5", + "thresholds": "15/29", + "tier": "3", + "type": "Support" + }, + { + "atk": "+3", + "attack": "Miasma Bolt", + "damage": "3d6+1 mag", + "description": "A cloaked demon-creature with long limbs, seeping shadows.", + "difficulty": "17", + "experience": "Manipulation +3", + "feature": [ + { + "name": "Depths of Despair - Passive", + "text": "The Demon deals double damage to PCs with 0 Hope." + }, + { + "name": "Your Struggle Is Pointless - Action", + "text": "**Spend a Fear** to weigh down the spirits of all PCs within Far range. All targets affected replace their Hope Die with a **d8** until they roll a success with Hope or their next rest." + }, + { + "name": "Your Friends Will Fail You - Reaction", + "text": "When a PC fails with Fear, you can **mark a Stress** to cause all other PCs within Close range to lose a Hope." + }, + { + "name": "Momentum - Reaction", + "text": "When the Demon makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Make fear contagious, stick to the shadows, undermine resolve", + "name": "Demon of Despair", + "range": "Far", + "stress": "5", + "thresholds": "18/35", + "tier": "3", + "type": "Skulk" + }, + { + "atk": "+4", + "attack": "Perfect Spear", + "damage": "3d10 phy", + "description": "A perfectly beautiful and infinitely cruel demon with a gleaming spear and elegant robes.", + "difficulty": "18", + "experience": "Manipulation +2", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Demon makes a successful attack, all PCs within Far range must lose a Hope and you gain a Fear." + }, + { + "name": "Double or Nothing - Passive", + "text": "When a PC within Far range fails a roll, they can choose to reroll their Fear Die and take the new result. If they still fail, they mark 2 Stress and the Demon clears a Stress." + }, + { + "name": "Unparalleled Skill - Action", + "text": "**Mark a Stress** to deal the Demon's standard attack damage to a target within Close range." + }, + { + "name": "The Root of Villainy - Action", + "text": "**Spend a Fear** to spotlight two other Demons within Far range." + }, + { + "name": "You Pale in Comparison - Reaction", + "text": "When a PC fails a roll within Close range of the Demon, they must mark a Stress." + } + ], + "hp": "7", + "motives_and_tactics": "Condescend, declare premature victory, prove superiority", + "name": "Demon of Hubris", + "range": "Very Close", + "stress": "5", + "thresholds": "18/36", + "tier": "3", + "type": "Leader" + }, + { + "atk": "+4", + "attack": "Psychic Assault", + "damage": "3d8+3 mag", + "description": "A fickle creature of spindly limbs and insatiable desires.", + "difficulty": "17", + "experience": "Manipulation +3", + "feature": [ + { + "name": "Unprotected Mind - Passive", + "text": "The Demon's standard attack deals direct damage." + }, + { + "name": "My Turn - Reaction", + "text": "When the Demon marks HP from an attack, **spend a number of Fear equal to the HP marked by the Demon** to cause the attacker to mark the same number of HP." + }, + { + "name": "Rivalry - Reaction", + "text": "When a creature within Close range takes damage from a different adversary, you can **mark a Stress** to add a **d4** to the damage roll." + }, + { + "name": "What's Yours Is Mine - Reaction", + "text": "When a PC takes Severe damage within Very Close range of the Demon, you can **spend a Fear** to cause the target to make a Finesse Reaction Roll. On a failure, the Demon seizes one item or consumable of their choice from the target's inventory." + } + ], + "hp": "6", + "motives_and_tactics": "Join in on others' success, take what belongs to others, hold grudges", + "name": "Demon of Jealousy", + "range": "Far", + "stress": "6", + "thresholds": "17/30", + "tier": "3", + "type": "Ranged" + }, + { + "atk": "+3", + "attack": "Fists", + "damage": "3d8+1 mag", + "description": "A hulking demon with boulder-sized fists, driven by endless rage.", + "difficulty": "17", + "experience": "Intimidation +2", + "feature": [ + { + "name": "Anger Unrelenting - Passive", + "text": "The Demon's attacks deal direct damage." + }, + { + "name": "Battle Lust - Action", + "text": "**Spend a Fear** to boil the blood of all PCs within Far range. They use a d20 as their Fear Die until the end of the scene." + }, + { + "name": "Retaliation - Reaction", + "text": "When the Demon takes damage from an attack within Close range, you can **mark a Stress** to make a standard attack against the attacker." + }, + { + "name": "Blood and Souls - Reaction: Countdown (Loop 6)", + "text": "Activate the first time an attack is made within sight of the Demon. It ticks down when a PC takes a violent action. When it triggers, summon **1d4** Minor Demons, who appear at Close range." + } + ], + "hp": "7", + "motives_and_tactics": "Fuel anger, impress rivals, wreak havoc", + "name": "Demon of Wrath", + "range": "Very Close", + "stress": "5", + "thresholds": "22/40", + "tier": "3", + "type": "Bruiser" + }, + { + "atk": "+2", + "attack": "Claws and Teeth", + "damage": "2d6+7 phy", + "description": "A wide-winged pet endlessly loyal to their vampire owner.", + "difficulty": "14", + "experience": "Bloodthirsty +3", + "feature": [ + { + "name": "Flying - Passive", + "text": "While flying, the Bat gains a +3 bonus to their Difficulty." + }, + { + "name": "Screech - Action", + "text": "**Mark a Stress** to send a high-pitch screech out toward all targets in front of the Bat within Far range. Those targets must mark **1d4** Stress." + }, + { + "name": "Guardian - Reaction", + "text": "When an allied Vampire marks HP, you can **mark a Stress** to fly into Melee range of the attacker and make an attack with advantage against them. On a success, deal **2d6+2** physical damage." + } + ], + "hp": "5", + "motives_and_tactics": "Dive-bomb, hide, protect leader", + "name": "Dire Bat", + "range": "Melee", + "stress": "3", + "thresholds": "16/30", + "tier": "3", + "type": "Skulk" + }, + { + "atk": "+4", + "attack": "Deadfall Shortbow", + "damage": "3d10+1 phy", + "description": "A nature spirit in the form of a humanoid tree.", + "difficulty": "16", + "experience": "Forest Knowledge +4", + "feature": [ + { + "name": "Bramble Patch - Action", + "text": "**Mark a Stress** to target a point within Far range. Create a patch of thorns that covers an area within Close range of that point. All targets within that area take **2d6+2** physical damage when they act. A target must succeed on a Finesse Roll or deal more than 20 damage to the Dryad with an attack to leave the area." + }, + { + "name": "Grow Saplings - Action", + "text": "**Spend a Fear** to grow three Treant Sapling Minions, who appear at Close range and immediately take the spotlight." + }, + { + "name": "We Are All One - Reaction", + "text": "When an ally dies within Close range, you can **spend a Fear** to clear 2 HP and 2 Stress as the fallen ally's life force is returned to the forest." + } + ], + "hp": "8", + "motives_and_tactics": "Command, cultivate, drive out, preserve the forest", + "name": "Dryad", + "range": "Far", + "stress": "5", + "thresholds": "24/38", + "tier": "3", + "type": "Leader" + }, + { + "atk": "+0", + "attack": "Bursts of Fire", + "damage": "5 mag", + "description": "A blazing mote of elemental fire.", + "difficulty": "15", + "feature": [ + { + "name": "Minion (9) - Passive", + "text": "The Elemental is defeated when they take any damage. For every 9 damage a PC deals to the Elemental, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Elemental Sparks within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Blast, consume, gain mass", + "name": "Elemental Spark", + "range": "Close", + "stress": "1", + "thresholds": "None", + "tier": "3", + "type": "Minion" + }, + { + "atk": "+7", + "attack": "Boulder Fist", + "damage": "3d10+1 phy", + "description": "A living landslide of boulders and dust, as large as a house.", + "difficulty": "17", + "feature": [ + { + "name": "Slow - Passive", + "text": "When you spotlight the Elemental and they don't have a token on their stat block, they can't act yet. Place a token on their stat block and describe what they're preparing to do. When you spotlight the Elemental and they have a token on their stat block, clear the token and they can act." + }, + { + "name": "Crushing Blows - Passive", + "text": "When the Elemental makes a successful attack, the target must mark an Armor Slot without receiving its benefits (they can still use armor to reduce the damage). If they can't mark an Armor Slot, they must mark an additional HP." + }, + { + "name": "Immovable Object - Passive", + "text": "An attack that would move the Elemental moves them two fewer ranges (for example, Far becomes Very Close). When the Elemental takes physical damage, reduce it by 7." + }, + { + "name": "Rockslide - Action", + "text": "**Mark a Stress** to create a rockslide that buries the land in front of Elemental within Close range with rockfall. All targets in this area must make an Agility Reaction Roll (19). Targets who fail take **2d12+5** physical damage and become _Vulnerable_ until their next roll with Hope. Targets who succeed take half damage." + }, + { + "name": "Momentum - Reaction", + "text": "When the Elemental makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "10", + "motives_and_tactics": "Avalanche, knock over, pummel", + "name": "Greater Earth Elemental", + "range": "Very Close", + "stress": "4", + "thresholds": "22/40", + "tier": "3", + "type": "Bruiser" + }, + { + "atk": "+3", + "attack": "Crashing Wave", + "damage": "3d4+1 mag", + "description": "A huge living wave that crashes down upon enemies.", + "difficulty": "17", + "feature": [ + { + "name": "Water Jet - Action", + "text": "**Mark a Stress** to attack a target within Very Close range. On a success, deal **2d4+7** physical damage and the target's next action has disadvantage. On a failure, the target must mark a Stress." + }, + { + "name": "Drowning Embrace - Action", + "text": "**Spend a Fear** to make an attack against all targets within Very Close range. Targets the Elemental succeeds against become _Restrained_ and _Vulnerable_ as they begin drowning. A target can break free, ending both conditions, with a successful Strength or Instinct Roll." + }, + { + "name": "High Tide - Reaction", + "text": "When the Elemental makes a successful standard attack, you can **mark a Stress** to knock the target back to Close range." + } + ], + "hp": "5", + "motives_and_tactics": "Deluge, disperse, drown", + "name": "Greater Water Elemental", + "range": "Very Close", + "stress": "5", + "thresholds": "17/34", + "tier": "3", + "type": "Support" + }, + { + "atk": "+3", + "attack": "Ooze Appendage", + "damage": "3d8+1 mag", + "description": "A translucent green mound of acid taller than most humans.", + "difficulty": "15", + "experience": "Blend In +3", + "feature": [ + { + "name": "Slow - Passive", + "text": "When you spotlight the Ooze and they don't have a token on their stat block, they can't act yet. Place a token on their stat block and describe what they're preparing to do. When you spotlight the Ooze and they have a token on their stat block, clear the token and they can act." + }, + { + "name": "Acidic Form - Passive", + "text": "When the Ooze makes a successful attack, the target must mark an Armor Slot without receiving its benefits (they can still use armor to reduce the damage). If they can't mark an Armor Slot, they must mark an additional HP." + }, + { + "name": "Envelop - Action", + "text": "Make an attack against a target within Melee range. On a success, the Ooze _Envelops_ them and the target must mark 2 Stress. While _Enveloped_, the target must mark an additional Stress every time they make an action roll. When the Ooze takes Severe damage, all _Enveloped_ targets are freed and the condition is cleared." + }, + { + "name": "Split - Reaction", + "text": "When the Ooze has 4 or more HP marked, you can **spend a Fear** to split them into two Green Oozes (with no marked HP or Stress). Immediately spotlight both of them." + } + ], + "hp": "7", + "motives_and_tactics": "Camouflage, creep up, envelop, multiply", + "name": "Huge Green Ooze", + "range": "Melee", + "stress": "4", + "thresholds": "15/30", + "tier": "3", + "type": "Skulk" + }, + { + "atk": "+3", + "attack": "Bite", + "damage": "2d12+2 phy", + "description": "A quadrupedal scaled beast with multiple long-necked heads, each filled with menacing fangs.", + "difficulty": "18", + "feature": [ + { + "name": "Many-Headed Menace - Passive", + "text": "The Hydra begins with three heads and can have up to five. When the Hydra takes Major or greater damage, they lose a head." + }, + { + "name": "Relentless (X) - Passive", + "text": "The Hydra can be spotlighted X times per GM turn, where X is the Hydra's number of heads. Spend Fear as usual to spotlight them." + }, + { + "name": "Regeneration - Action", + "text": "If the Hydra has any marked HP, **spend a Fear** to clear a HP and grow two heads." + }, + { + "name": "Terrifying Chorus - Action", + "text": "All PCs within Far range lose 2 Hope." + }, + { + "name": "Magical Weakness - Reaction", + "text": "When the Hydra takes magic damage, they become _Dazed_ until the next roll with Fear. While _Dazed_, they can't use their Regeneration action but are immune to magic damage." + } + ], + "hp": "10", + "motives_and_tactics": "Devour, regenerate, terrify", + "name": "Hydra", + "range": "Close", + "stress": "5", + "thresholds": "19/35", + "tier": "3", + "type": "Solo" + }, + { + "atk": "+0", + "attack": "Warhammer", + "damage": "3d6+3 phy", + "description": "The sovereign ruler of a nation, wreathed in the privilege of tradition and wielding unmatched power in their domain.", + "difficulty": "16", + "experience": "History +3, Nobility +3", + "feature": [ + { + "name": "Execute Them! - Action", + "text": "**Spend a Fear** per PC in the party to have the group condemned for crimes real or imagined. A PC who succeeds on a Presence Roll can demand trial by combat or another special form of trial." + }, + { + "name": "Crownsguard - Action", + "text": "Once per scene, **mark a Stress** to summon six Tier 3 Minions, who appear at Close range to enforce the Monarch's will." + }, + { + "name": "Casus Belli - Reaction: Long-Term Countdown (8)", + "text": "**Spend a Fear** to activate after the Monarch's desire for war is first revealed. When it triggers, the Monarch has a reason to rally the nation to war and the support to act on that reason. You gain **1d4** Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Control vassals, destroy rivals, forge a legacy", + "name": "Monarch", + "range": "Melee", + "stress": "5", + "thresholds": "16/32", + "tier": "3", + "type": "Social" + }, + { + "atk": "+3", + "attack": "Bramble Sword", + "damage": "3d8+3 phy", + "description": "A knight with huge, majestic antlers wearing armor made of dangerous thorns.", + "difficulty": "17", + "experience": "Forest Knowledge +3", + "feature": [ + { + "name": "From Above - Passive", + "text": "When the Knight succeeds on a standard attack from above a target, they deal **3d12+3** physical damage instead of their standard damage." + }, + { + "name": "Blade of the Forest - Action", + "text": "**Spend a Fear** to make an attack against all targets within Very Close range. Targets the Knight succeeds against take physical damage equal to **3d4** + the target's Major threshold." + }, + { + "name": "Thorny Armor - Reaction", + "text": "When the Knight takes damage from an attack within Melee range, you can **mark a Stress** to deal **1d10+5** physical damage to the attacker." + } + ], + "hp": "7", + "motives_and_tactics": "Isolate, maneuver, protect the forest, weed the unwelcome", + "name": "Stag Knight", + "range": "Melee", + "stress": "5", + "thresholds": "19/36", + "tier": "3", + "type": "Standard" + }, + { + "atk": "+2", + "attack": "Branch", + "damage": "3d8+2 phy", + "description": "A sturdy animate old-growth tree.", + "difficulty": "17", + "experience": "Forest Knowledge +3", + "feature": [ + { + "name": "Just a Tree - Passive", + "text": "Before they make their first attack in a fight or after they become _Hidden_, the Treant is indistinguishable from other trees until they next act or a PC succeeds on an Instinct Roll to identify them." + }, + { + "name": "Seed Barrage - Action", + "text": "**Mark a Stress** and make an attack against up to three targets within Close range, pummeling them with giant acorns. Targets the Treant succeeds against take **2d10+5** physical damage." + }, + { + "name": "Take Root - Action", + "text": "**Mark a Stress** to _Root_ the Treant in place. The Treant is _Restrained_ while _Rooted_, and can end this effect instead of moving while they are spotlighted. While Rooted, the Treant has resistance to physical damage." + } + ], + "hp": "7", + "motives_and_tactics": "Hide in plain sight, preserve the forest, root down, swing branches", + "name": "Oak Treant", + "range": "Very Close", + "stress": "4", + "thresholds": "22/40", + "tier": "3", + "type": "Bruiser" + }, + { + "atk": "+5", + "attack": "Rapier", + "damage": "2d20+4 phy", + "description": "A captivating undead dressed in aristocratic finery.", + "difficulty": "17", + "experience": "Aristocrat +3", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Vampire makes a successful attack, all PCs within Far range lose a Hope and you gain a Fear." + }, + { + "name": "Look into My Eyes - Passive", + "text": "A creature who moves into Melee range of the Vampire must make an Instinct Reaction Roll. On a failure, you gain **1d4** Fear." + }, + { + "name": "Feed on Followers - Action", + "text": "When the Vampire is within Melee range of an ally, they can cause the ally to mark a HP. The Vampire then clears a HP." + }, + { + "name": "The Hunt Is On - Action", + "text": "**Spend 2 Fear** to summon **1d4** Vampires, who appear at Far range and immediately take the spotlight." + }, + { + "name": "Lifesuck - Reaction", + "text": "When the Vampire is spotlighted, roll a **d8**. On a result of 6 or higher, all targets within Very Close range must mark a HP." + } + ], + "hp": "6", + "motives_and_tactics": "Create thralls, charm, command, fly, intimidate", + "name": "Head Vampire", + "range": "Melee", + "stress": "6", + "thresholds": "22/42", + "tier": "3", + "type": "Leader" + }, + { + "atk": "+0", + "attack": "Branches", + "damage": "8 phy", + "description": "A small, sentient tree sapling.", + "difficulty": "14", + "feature": [ + { + "name": "Minion (6) - Passive", + "text": "The Sapling is defeated when they take any damage. For every 6 damage a PC deals to the Sapling, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Treant Saplings within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 8 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Blend in, preserve the forest, pummel, surround", + "name": "Treant Sapling", + "range": "Melee", + "stress": "1", + "thresholds": "None", + "tier": "3", + "type": "Minion" + }, + { + "atk": "+3", + "attack": "Rapier", + "damage": "3d8 phy", + "description": "An intelligent undead with blood-stained lips and a predator's smile.", + "difficulty": "16", + "experience": "Nocturnal Hunter +3", + "feature": [ + { + "name": "Draining Bite - Action", + "text": "Make an attack against a target within Melee range. On a success, deal **5d4** physical damage. A target who marks HP from this attack loses a Hope and must mark a Stress. The Vampire then clears a HP." + }, + { + "name": "Mistform - Reaction", + "text": "When the Vampire takes physical damage, you can **spend a Fear** to take half damage." + } + ], + "hp": "5", + "motives_and_tactics": "Bite, charm, deceive, feed, intimidate", + "name": "Vampire", + "range": "Melee", + "stress": "4", + "thresholds": "18/35", + "tier": "3", + "type": "Standard" + }, + { + "atk": "+2", + "attack": "Body Bash", + "damage": "3d6+2 phy", + "description": "A boxy, dust-covered construct with thick metallic swinging doors on their torso.", + "difficulty": "16", + "feature": [ + { + "name": "Blocking Shield - Passive", + "text": "Creatures within Melee range of the Gaoler have disadvantage on attack rolls against them. Creatures trapped inside the Gaoler are immune to this feature." + }, + { + "name": "Lock Up - Action", + "text": "**Mark a Stress** to make an attack against a target within Very Close range. On a success, the target is _Restrained_ within the Gaoler until freed with a successful Strength Roll (18). While _Restrained_, the target can only attack the Gaoler." + } + ], + "hp": "5", + "motives_and_tactics": "Carry away, entrap, protect, pummel", + "name": "Vault Guardian Gaoler", + "range": "Very Close", + "stress": "3", + "thresholds": "19/33", + "tier": "3", + "type": "Support" + }, + { + "atk": "+3", + "attack": "Charged Mace", + "damage": "2d12+1 phy", + "description": "A dust-covered golden construct with boxy limbs and a huge mace for a hand.", + "difficulty": "17", + "feature": [ + { + "name": "Kinetic Slam - Passive", + "text": "Targets who take damage from the Sentinel's standard attack are knocked back to Very Close range." + }, + { + "name": "Box In - Action", + "text": "**Mark a Stress** to choose a target within Very Close range to focus on. That target has disadvantage on attack rolls when they're within Very Close range of the Sentinel. The Sentinel can only focus on one target at a time." + }, + { + "name": "Mana Bolt - Action", + "text": "**Spend a Fear** to lob explosive magic at a point within Far range. All targets within Very Close range of that point must make an Agility Reaction Roll. Targets who fail take **2d8+20** magic damage and are knocked back to Close range. Targets who succeed take half damage and aren't knocked back." + }, + { + "name": "Momentum - Reaction", + "text": "When the Sentinel makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "6", + "motives_and_tactics": "Destroy at any cost, expunge, protect", + "name": "Vault Guardian Sentinel", + "range": "Very Close", + "stress": "3", + "thresholds": "21/40", + "tier": "3", + "type": "Bruiser" + }, + { + "atk": "+3", + "attack": "Magitech Cannon", + "damage": "3d10+3 mag", + "description": "A massive living turret with reinforced armor and twelve pistondriven mechanical legs.", + "difficulty": "16", + "feature": [ + { + "name": "Slow Firing - Passive", + "text": "When you spotlight the Turret and they don't have a token on their stat block, they can't make a standard attack. Place a token on their stat block and describe what they're preparing to do. When you spotlight the Turret and they have a token on their stat block, clear the token and they can attack." + }, + { + "name": "Mark Target - Action", + "text": "**Spend a Fear** to _Mark_ a target within Far range until the Turret is destroyed or the _Marked_ target becomes _Hidden_. While the target is _Marked_, their Evasion is halved." + }, + { + "name": "Concentrate Fire - Reaction", + "text": "When another adversary deals damage to a target within Far range of the Turret, you can **mark a Stress** to add the Turret's standard attack damage to the damage roll." + }, + { + "name": "Detonation - Reaction", + "text": "When the Turret is destroyed, they explode. All targets within Close range must make an Agility Reaction Roll. Targets who fail take **3d20** physical damage. Targets who succeed take half damage." + } + ], + "hp": "5", + "motives_and_tactics": "Concentrate fire, lock down, mark, protect", + "name": "Vault Guardian Turret", + "range": "Far", + "stress": "4", + "thresholds": "20/32", + "tier": "3", + "type": "Ranged" + }, + { + "atk": "+7", + "attack": "Bite and Claws", + "damage": "4d10 phy", + "description": "A glacier-blue dragon with four powerful limbs and frost-tinged wings.", + "difficulty": "18", + "experience": "Protect What Is Mine +3", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Dragon can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Rend and Crush - Passive", + "text": "If a target damaged by the Dragon doesn't mark an Armor Slot to reduce the damage, they must mark a Stress." + }, + { + "name": "No Hope - Passive", + "text": "When a PC rolls with Fear while within Far range of the Dragon, they lose a Hope." + }, + { + "name": "Blizzard Breath - Action", + "text": "**Spend 2 Fear** to release an icy whorl in front of the Dragon within Close range. All targets in this area must make an Agility Reaction Roll. Targets who fail take **4d6+5** magic damage and are _Restrained_ by ice until they break free with a successful Strength Roll. Targets who succeed must mark 2 Stress or take half damage." + }, + { + "name": "Avalanche - Action", + "text": "**Spend a Fear** to have the Dragon unleash a huge downfall of snow and ice, covering all other creatures within Far range. All targets within this area must succeed on an Instinct Reaction Roll or be buried in snow and rocks, becoming _Vulnerable_ until they dig themselves out from the debris. For each PC that fails the reaction roll, you gain a Fear." + }, + { + "name": "Frozen Scales - Reaction", + "text": "When a creature makes a successful attack against the Dragon from within Very Close range, they must mark a Stress and become _Chilled_ until their next rest or they clear a Stress. While they are _Chilled_, they have disadvantage on attack rolls." + }, + { + "name": "Momentum - Reaction", + "text": "When the Dragon makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "10", + "motives_and_tactics": "Avalanche, defend lair, fly, freeze, defend what is mine, maul", + "name": "Young Ice Dragon", + "range": "Close", + "stress": "6", + "thresholds": "21/41", + "tier": "3", + "type": "Solo" + }, + { + "atk": "+6", + "attack": "Necrotic Blast", + "damage": "4d12+8 mag", + "description": "A decaying mage adorned in dark, tattered robes.", + "difficulty": "21", + "experience": "Forbidden Knowledge +3, Wisdom of Centuries +3", + "feature": [ + { + "name": "Dance of Death - Action", + "text": "**Mark a Stress** to spotlight **1d4** allies. Attacks they make while spotlighted in this way deal half damage, or full damage if you **spend a Fear**." + }, + { + "name": "Beam of Decay - Action", + "text": "**Mark 2 Stress** to cause all targets within Far range to make a Strength Reaction Roll. Targets who fail take **2d20+12** magic damage and you gain a Fear. Targets who succeed take half damage. A target who marks 2 or more HP must also mark **2 Stress** and becomes _Vulnerable_ until they roll with Hope." + }, + { + "name": "Open the Gates of Death - Action", + "text": "**Spend a Fear** to summon a Zombie Legion, which appears at Close range and immediately takes the spotlight." + }, + { + "name": "Not Today, My Dears - Reaction", + "text": "When the Necromancer has marked 7 or more of their HP, you can **spend a Fear** to have them teleport away to a safe location to recover. A PC who succeeds on an Instinct Roll can trace the teleportation magic to their destination." + }, + { + "name": "Your Life Is Mine - Reaction: Countdown (Loop 2d6)", + "text": "When the Necromancer has marked 6 or more of their HP, activate the countdown. When it triggers, deal **2d10+6** direct magic damage to a target within Close range. The Necromancer then **clears a number of Stress or HP** equal to the number of HP marked by the target from this attack." + } + ], + "hp": "9", + "motives_and_tactics": "Corrupt, decay, flee to fight another day, resurrect", + "name": "Arch-Necromancer", + "range": "Far", + "stress": "8", + "thresholds": "33/66", + "tier": "4", + "type": "Leader" + }, + { + "atk": "+2", + "attack": "Cursed Axe", + "damage": "12 phy", + "description": "A cursed soul bound to the Fallen's will.", + "difficulty": "18", + "feature": [ + { + "name": "Minion (12) - Passive", + "text": "The Shock Troop is defeated when they take any damage. For every 12 damage a PC deals to the Shock Troop, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Aura of Doom - Passive", + "text": "When a PC marks HP from an attack by the Shock Troop, they lose a Hope." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Fallen Shock Troops within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 12 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Crush, dominate, earn relief, punish", + "name": "Fallen Shock Troop", + "range": "Very Close", + "stress": "1", + "thresholds": "None", + "tier": "4", + "type": "Minion" + }, + { + "atk": "+4", + "attack": "Corrupted Staff", + "damage": "4d6+10 mag", + "description": "A powerful mage bound by the bargains they made in life.", + "difficulty": "19", + "experience": "Ancient Knowledge +2", + "feature": [ + { + "name": "Conflagration - Action", + "text": "**Spend a Fear** to unleash an all-consuming firestorm and make an attack against all targets within Close range. Targets the Sorcerer succeeds against take **2d10+6** direct magic damage." + }, + { + "name": "Nightmare Tableau - Action", + "text": "**Mark a Stress** to trap a target within Far range in a powerful illusion of their worst fears. While trapped, the target is _Restrained_ and _Vulnerable_ until they break free, ending both conditions, with a successful Instinct Roll." + }, + { + "name": "Slippery - Reaction", + "text": "When the Sorcerer takes damage from an attack, they can teleport up to Far range." + }, + { + "name": "Shackles of Guilt - Reaction: Countdown (Loop 2d6)", + "text": "When the Sorcerer is in the spotlight for the first time, activate the countdown. When it triggers, all targets within Far range become _Vulnerable_ and must mark a Stress as they relive their greatest regrets. A target can break free from their regret with a successful Presence or Strength Roll. When a PC fails to break free, they lose a Hope." + } + ], + "hp": "6", + "motives_and_tactics": "Acquire, dishearten, dominate, torment", + "name": "Fallen Sorcerer", + "range": "Far", + "stress": "5", + "thresholds": "26/42", + "tier": "4", + "type": "Support" + }, + { + "atk": "+7", + "attack": "Barbed Whip", + "damage": "4d8+7 phy", + "description": "A Fallen God, wreathed in rage and resentment, bearing millennia of experience in breaking heroes' spirits.", + "difficulty": "20", + "experience": "Conquest +3, History +2, Intimidation +3", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Realm-Breaker can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Firespite Plate Armor - Passive", + "text": "When the Realm-Breaker takes damage, reduce it by **2d10**." + }, + { + "name": "Tormenting Lash - Action", + "text": "**Mark a Stress** to make a standard attack against all targets within Very Close range. When a target uses armor to reduce damage from this attack, they must mark 2 Armor Slots." + }, + { + "name": "All-Consuming Rage - Reaction: Countdown (Decreasing 8)", + "text": "When the Realm-Breaker is in the spotlight for the first time, activate the countdown. When it triggers, create a torrent of incarnate rage that rends flesh from bone. All targets within Far range must make a Presence Reaction Roll. Targets who fail take **2d6+10** direct magic damage. Targets who succeed take half damage. For each HP marked from this damage, summon a Fallen Shock Troop within Very Close range of the target who marked that HP. If the countdown ever decreases its maximum value to 0, the Realm-Breaker marks their remaining HP and all targets within Far range must mark all remaining HP and make a death move." + }, + { + "name": "Doombringer - Reaction", + "text": "When a target marks HP from an attack by the Realm-Breaker, all PCs within Far range of the target must lose a Hope." + }, + { + "name": "I Have Never Known Defeat (Phase Change) - Reaction", + "text": "When the Realm-Breaker marks their last HP, replace them with the Undefeated Champion and immediately spotlight them." + } + ], + "hp": "8", + "motives_and_tactics": "Corrupt, dominate, punish, break the weak", + "name": "Fallen Warlord: Realm-Breaker", + "range": "Close", + "stress": "5", + "thresholds": "36/66", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+8", + "attack": "Heart-Shattering Sword", + "damage": "4d12+13 phy", + "description": "That which only the most feared have a chance to fear.", + "difficulty": "18", + "experience": "Conquest +3, History +2, Intimidation +3", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Undefeated Champion can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Faltering Armor - Passive", + "text": "When the Undefeated Champion takes damage, reduce it by **1d10**." + }, + { + "name": "Shattering Strike - Action", + "text": "**Mark a Stress** to make a standard attack against all targets within Very Close range. PCs the Champion succeeds against lose a number of Hope equal to the HP they marked from this attack." + }, + { + "name": "Endless Legions - Action", + "text": "**Spend a Fear** to summon a number of Fallen Shock Troops equal to twice the number of PCs. The Shock Troops appear at Far range." + }, + { + "name": "Circle of Defilement - Reaction: Countdown (1d8)", + "text": "When the Undefeated Champion is in the spotlight for the first time, activate the countdown. When it triggers, activate a magical circle covering an area within Far range of the Champion. A target within that area is _Vulnerable_ until they leave the circle. The circle can be removed by dealing Severe damage to the Undefeated Champion." + }, + { + "name": "Momentum - Reaction", + "text": "When the Undefeated Champion makes a successful attack against a PC, you gain a Fear." + }, + { + "name": "Doombringer - Reaction", + "text": "When a target marks HP from an attack by the Undefeated Champion, all PCs within Far range of the target lose a Hope." + } + ], + "hp": "11", + "motives_and_tactics": "Dispatch merciless death, punish the defiant, secure victory at any cost", + "name": "Fallen Warlord: Undefeated Champion", + "range": "Very Close", + "stress": "5", + "thresholds": "35/58", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+4", + "attack": "Sanctified Longbow", + "damage": "4d8+8 phy", + "description": "Spirit soldiers with sanctified bows.", + "difficulty": "19", + "feature": [ + { + "name": "Punish the Guilty - Passive", + "text": "The Archer deals double damage to targets marked _Guilty_ by a High Seraph." + }, + { + "name": "Divine Volley - Action", + "text": "**Mark a Stress** to make a standard attack against up to three targets." + } + ], + "hp": "3", + "motives_and_tactics": "Focus fire, obey, reposition, volley", + "name": "Hallowed Archer", + "range": "Far", + "stress": "2", + "thresholds": "25/45", + "tier": "4", + "type": "Ranged" + }, + { + "atk": "+2", + "attack": "Sword and Shield", + "damage": "10 phy", + "description": "Souls of the faithful, lifted up with divine weaponry.", + "difficulty": "18", + "feature": [ + { + "name": "Minion (13) - Passive", + "text": "The Soldier is defeated when they take any damage. For every 13 damage a PC deals to the Soldier, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Divine Flight - Passive", + "text": "While the Soldier is flying, **spend a Fear** to move up to Far range instead of Close range before taking an action." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Hallowed Soldiers within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 10 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Obey, outmaneuver, punish, swarm", + "name": "Hallowed Soldier", + "range": "Melee", + "stress": "2", + "thresholds": "None", + "tier": "4", + "type": "Minion" + }, + { + "atk": "+8", + "attack": "Holy Sword", + "damage": "4d10+10 phy", + "description": "A divine champion, head of a hallowed host of warriors who enforce their god's will.", + "difficulty": "20", + "experience": "Divine Knowledge +3", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Seraph can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Divine Flight - Passive", + "text": "While the Seraph is flying, **spend a Fear** to move up to Far range instead of Close range before taking an action." + }, + { + "name": "Judgment - Action", + "text": "**Spend a Fear** to make a target _Guilty_ in the eyes of the Seraph's god until the Seraph is defeated. While _Guilty_, the target doesn't gain Hope on a result with Hope. When the Seraph succeeds on a standard attack against a _Guilty_ target, they deal Severe damage instead of their standard damage. The Seraph can only mark one target at a time." + }, + { + "name": "God Rays - Action", + "text": "**Mark a Stress** to reflect a sliver of divinity as a searing beam of light that hits up to twenty targets within Very Far range. Targets must make a Presence Reaction Roll, with disadvantage if they are marked _Guilty_. Targets who fail take **4d6+12** magic damage. Targets who succeed take half damage." + }, + { + "name": "We Are One - Action", + "text": "Once per scene, **spend a Fear** to spotlight all other adversaries within Far range. Attacks they make while spotlighted in this way deal half damage." + } + ], + "hp": "7", + "motives_and_tactics": "Enforce dogma, fly, pronounce judgment, smite", + "name": "High Seraph", + "range": "Very Close", + "stress": "5", + "thresholds": "37/70", + "tier": "4", + "type": "Leader" + }, + { + "atk": "+7", + "attack": "Tentacles", + "damage": "4d12+10 phy", + "description": "A legendary beast of the sea, bigger than the largest galleon, with sucker-laden tentacles and a terrifying maw.", + "difficulty": "20", + "experience": "Swimming +3", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Kraken can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Many Tentacles - Passive", + "text": "While the Kraken has 7 or fewer marked HP, they can make their standard attack against two targets within range." + }, + { + "name": "Grapple and Drown - Action", + "text": "Make an attack roll against a target within Close range. On a success, **mark a Stress** to grab them with a tentacle and drag them beneath the water. The target is _Restrained_ and _Vulnerable_ until they break free with a successful Strength Roll or the Kraken takes Major or greater damage. While _Restrained_ and _Vulnerable_ in this way, a target must mark a Stress when they make an action roll." + }, + { + "name": "Boiling Blast - Action", + "text": "**Spend a Fear** to spew a line of boiling water at any number of targets in a line up to Far range. All targets must succeed on an Agility Reaction Roll or take **4d6+9** physical damage. If a target marks an Armor Slot to reduce the damage, they must also mark a Stress." + }, + { + "name": "Momentum - Reaction", + "text": "When the Kraken makes a successful attack against a PC, you gain a Fear." + } + ], + "hp": "11", + "motives_and_tactics": "Consume, crush, drown, grapple", + "name": "Kraken", + "range": "Close", + "stress": "8", + "thresholds": "35/70", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+8", + "attack": "Psychic Attack", + "damage": "4d8+9 mag", + "description": "A towering immortal and incarnation of fate, cursed to only see bad outcomes.", + "difficulty": "20", + "experience": "Boundless Knowledge +4", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Oracle makes a successful attack, all PCs within Far range lose a Hope and you gain a Fear." + }, + { + "name": "Walls Closing In - Passive", + "text": "When a creature rolls a failure while within Very Far range of the Oracle, they must mark a Stress." + }, + { + "name": "Pronounce Fate - Action", + "text": "**Spend a Fear** to present a target within Far range with a vision of their personal nightmare. The target must make a Knowledge Reaction Roll. On a failure, they lose all Hope and take **2d20+4** direct magic damage. On a success, they take half damage and lose a Hope." + }, + { + "name": "Summon Tormentors - Action", + "text": "Once per day, **spend 2 Fear** to summon **2d4** Tier 2 or below Minions relevant to one of the PC's personal nightmares. They appear at Close range relative to that PC." + }, + { + "name": "Ominous Knowledge - Reaction", + "text": "When the Oracle sees a mortal creature, they instantly know one of their personal nightmares." + }, + { + "name": "Vengeful Fate - Reaction", + "text": "When the Oracle marks HP from an attack within Very Close range, you can **mark a Stress** to knock the attacker back to Far range and deal **2d10+4** physical damage." + } + ], + "hp": "11", + "motives_and_tactics": "Change environment, condemn, dishearten, toss aside", + "name": "Oracle of Doom", + "range": "Far", + "stress": "10", + "thresholds": "38/68", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+2d4", + "attack": "Massive Pseudopod", + "damage": "4d6+13 mag", + "description": "A chaotic mockery of life, constantly in flux.", + "difficulty": "19", + "feature": [ + { + "name": "Chaotic Form - Passive", + "text": "When the Abomination attacks, roll **2d4** and use the result as their attack modifier." + }, + { + "name": "Disorienting Presence - Passive", + "text": "When a target takes damage from the Abomination, they must make an Instinct Reaction Roll. On a failure, they gain disadvantage on their next action roll and you gain a Fear." + }, + { + "name": "Reality Quake - Action", + "text": "**Spend a Fear** to rattle the edges of reality within Far range of the Abomination. All targets within that area must succeed on a Knowledge Reaction Roll or become _Unstuck_ from reality until the end of the scene. When an _Unstuck_ target spends Hope or marks Armor Slots, HP, or Stress, they must double the amount spent or marked." + }, + { + "name": "Unreal Form - Reaction", + "text": "When the Abomination takes damage, reduce it by **1d20**. If the Abomination marks 1 or fewer Hit Points from a successful attack against them, you gain a Fear." + } + ], + "hp": "7", + "motives_and_tactics": "Demolish, devour, undermine", + "name": "Outer Realms Abomination", + "range": "Very Close", + "stress": "5", + "thresholds": "35/71", + "tier": "4", + "type": "Bruiser" + }, + { + "atk": "+7", + "attack": "Corroding Pseudopod", + "damage": "4d8+5 mag", + "description": "A shifting, formless mass seemingly made of chromatic light.", + "difficulty": "19", + "feature": [ + { + "name": "Will-Shattering Touch - Passive", + "text": "When a PC takes damage from the Corruptor, they lose a Hope." + }, + { + "name": "Disgorge Reality Flotsam - Action", + "text": "**Mark a Stress** to spew partially digested portions of consumed realities at all targets within Close range. Targets must succeed on a Knowledge Reaction Roll or mark 2 Stress." + } + ], + "hp": "4", + "motives_and_tactics": "Confuse, distract, overwhelm", + "name": "Outer Realms Corruptor", + "range": "Very Close", + "stress": "3", + "thresholds": "27/47", + "tier": "4", + "type": "Support" + }, + { + "atk": "+3", + "attack": "Claws and Teeth", + "damage": "11 phy", + "description": "A vaguely humanoid form stripped of memory and identity.", + "difficulty": "17", + "feature": [ + { + "name": "Minion (13) - Passive", + "text": "The Thrall is defeated when they take any damage. For every 13 damage a PC deals to the Thrall, defeat an additional Minion within range the attack would succeed against." + }, + { + "name": "Group Attack - Action", + "text": "**Spend a Fear** to choose a target and spotlight all Outer Realm Thralls within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 11 physical damage each. Combine this damage." + } + ], + "hp": "1", + "motives_and_tactics": "Destroy, disgust, disorient, intimidate", + "name": "Outer Realms Thrall", + "range": "Very Close", + "stress": "1", + "thresholds": "None", + "tier": "4", + "type": "Minion" + }, + { + "atk": "+8", + "attack": "Obsidian Claws", + "damage": "4d10+4 phy", + "description": "A massive winged creature with obsidian scales and impossibly sharp claws.", + "difficulty": "19", + "experience": "Hunt from Above +5", + "feature": [ + { + "name": "Relentless (2) - Passive", + "text": "The Obsidian Predator can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Flying - Passive", + "text": "While flying, the Obsidian Predator gains a +3 bonus to their Difficulty." + }, + { + "name": "Obsidian Scales - Passive", + "text": "The Obsidian Predator is resistant to physical damage." + }, + { + "name": "Avalanche Tail - Action", + "text": "**Mark a Stress** to make an attack against all targets within Close range. Targets the Obsidian Predator succeeds against take **4d6+4** physical damage and are knocked back to Far range and _Vulnerable_ until their next roll with Hope." + }, + { + "name": "Dive-Bomb - Action", + "text": "If the Obsidian Predator is flying, **mark a Stress** to choose a point within Far range. Move to that point and make an attack against all targets within Very Close range. Targets the Obsidian Predator succeeds against take **2d10+6** physical damage and must mark a Stress and lose a Hope." + }, + { + "name": "Erupting Rage (Phase Change) - Reaction", + "text": "When the Obsidian Predator marks their last HP, replace them with the Molten Scourge and immediately spotlight them." + } + ], + "hp": "6", + "motives_and_tactics": "Defend lair, dive-bomb, fly, hunt, intimidate", + "name": "Volcanic Dragon: Obsidian Predator", + "range": "Close", + "stress": "5", + "thresholds": "33/65", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+9", + "attack": "Lava-Coated Claws", + "damage": "4d12+4 phy", + "description": "Enraged by their wounds, the dragon bursts into molten lava.", + "difficulty": "20", + "experience": "Hunt from Above +5", + "feature": [ + { + "name": "Relentless (3) - Passive", + "text": "The Molten Scourge can be spotlighted up to three times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Cracked Scales - Passive", + "text": "When the Molten Scourge takes damage, roll a number of **d6s** equal to HP marked. For each result of 4 or higher, you gain a Fear." + }, + { + "name": "Shattering Might - Action", + "text": "**Mark a Stress** to make an attack against a target within Very Close range. On a success, the target takes **4d8+1** physical damage, loses a Hope, and is knocked back to Close range. The Molten Scourge clears a Stress." + }, + { + "name": "Eruption - Action", + "text": "**Spend a Fear** to erupt lava from beneath the Molten Scourge's scales, filling the area within Very Close range with molten lava. All targets in that area must succeed on an Agility Reaction Roll or take **4d6+6** physical damage and be knocked back to Close range. This area remains lava. When a creature other than the Molten Scourge enters that area or acts while inside of it, they must mark 6 HP." + }, + { + "name": "Volcanic Breath - Reaction", + "text": "When the Molten Scourge takes Major damage, roll a **d10**. On a result of 8 or higher, the Molten Scourge breathes a flow of lava in front of them within Far range. All targets in that area must make an Agility Reaction Roll. Targets who fail take **2d10+4** physical damage, mark **1d4 Stress**, and are _Vulnerable_ until they clear a Stress. Targets who succeed take half damage and must mark a Stress." + }, + { + "name": "Lava Splash - Reaction", + "text": "When the Molten Scourge takes Severe damage from an attack within Very Close range, molten blood gushes from the wound and deals **2d10+4** direct physical damage to the attacker." + }, + { + "name": "Ashen Vengeance (Phase Change) - Reaction", + "text": "When the Molten Scourge marks their last HP, replace them with the Ashen Tyrant and immediately spotlight them." + } + ], + "hp": "7", + "motives_and_tactics": "Douse with lava, incinerate, repel Invaders, reposition", + "name": "Volcanic Dragon: Molten Scourge", + "range": "Close", + "stress": "5", + "thresholds": "30/58", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+10", + "attack": "Claws and Teeth", + "damage": "4d12+15 phy", + "description": "No enemy has ever had the insolence to wound the dragon so. As the lava settles, it's ground to ash like the dragon's past foes.", + "difficulty": "18", + "experience": "Hunt from Above +5", + "feature": [ + { + "name": "Relentless (4) - Passive", + "text": "The Ashen Tyrant can be spotlighted up to four times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Cornered - Passive", + "text": "**Mark a Stress** instead of spending a Fear to spotlight the Ashen Tyrant." + }, + { + "name": "Injured Wings - Passive", + "text": "While flying, the Ashen Tyrant gains a +1 bonus to their Difficulty." + }, + { + "name": "Ashes to Ashes - Passive", + "text": "When a PC rolls a failure while within Close range of the Ashen Tyrant, they lose a Hope and you gain a Fear. If the PC can't lose a Hope, they must mark a HP." + }, + { + "name": "Desperate Rampage - Action", + "text": "**Mark a Stress** to make an attack against all targets within Close range. Targets the Ashen Tyrant succeeds against take **2d20+2** physical damage, are knocked back to Close range of where they were, and must mark a Stress." + }, + { + "name": "Ashen Cloud - Action", + "text": "**Spend a Fear** to smash the ground and kick up ash within Far range. While within the ash cloud, a target has disadvantage on action rolls. The ash cloud clears the next time an adversary is spotlighted." + }, + { + "name": "Apocalyptic Thrashing - Action: Countdown (1d12)", + "text": "**Spend a Fear** to activate. It ticks down when a PC rolls with Fear. When it triggers, the Ashen Tyrant thrashes about, causing environmental damage (such as an earthquake, avalanche, or collapsing walls). All targets within Far range must make a Strength Reaction Roll. Targets who fail take **2d10+10** physical damage and are _Restrained_ by the rubble until they break free with a successful Strength Roll. Targets who succeed take half damage. If the Ashen Tyrant is defeated while this countdown is active, trigger the countdown immediately as the destruction caused by their death throes." + } + ], + "hp": "8", + "motives_and_tactics": "Choke, fly, intimidate, kill or be killed", + "name": "Volcanic Dragon: Ashen Tyrant", + "range": "Close", + "stress": "5", + "thresholds": "29/55", + "tier": "4", + "type": "Solo" + }, + { + "atk": "+4", + "attack": "Greataxe", + "damage": "4d12+15 phy", + "description": "A towering, muscular zombie with magically infused strength and skill.", + "difficulty": "20", + "feature": [ + { + "name": "Terrifying - Passive", + "text": "When the Zombie makes a successful attack, all PCs within Far range lose a Hope and you gain a Fear." + }, + { + "name": "Fearsome Presence - Passive", + "text": "PCs can't spend Hope to use features against the Zombie." + }, + { + "name": "Perfect Strike - Action", + "text": "**Mark a Stress** to make a standard attack against all targets within Very Close range. Targets the Zombie succeeds against are _Vulnerable_ until their next rest." + }, + { + "name": "Skilled Opportunist - Reaction", + "text": "When another adversary deals damage to a target within Very Close range of the Zombie, you can **spend a Fear** to add the Zombie's standard attack damage to the damage roll." + } + ], + "hp": "9", + "motives_and_tactics": "Consume, hound, maim, terrify", + "name": "Perfected Zombie", + "range": "Very Close", + "stress": "4", + "thresholds": "40/70", + "tier": "4", + "type": "Bruiser" + }, + { + "atk": "+2", + "attack": "Undead Hands", + "damage": "4d6+10 phy", + "description": "A large pack of undead, still powerful despite their rotting flesh.", + "difficulty": "17", + "feature": [ + { + "name": "Horde (2d6+5) - Passive", + "text": "When the Legion has marked half or more of their HP, their standard attack deals **2d6+5** physical damage instead." + }, + { + "name": "Unyielding - Passive", + "text": "The Legion has resistance to physical damage." + }, + { + "name": "Relentless (2) - Passive", + "text": "The Legion can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them." + }, + { + "name": "Overwhelm - Reaction", + "text": "When the Legion takes Minor damage from an attack within Melee range, you can **mark a Stress** to make a standard attack with advantage against the attacker." + } + ], + "hp": "8", + "motives_and_tactics": "Consume brain, shred flesh, surround", + "name": "Zombie Legion", + "range": "Close", + "stress": "5", + "thresholds": "25/45", + "tier": "4", + "type": "Horde (3/HP)" + } +] \ No newline at end of file diff --git a/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/Fixtures/environments.json b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/Fixtures/environments.json new file mode 100644 index 0000000..4b46938 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/Fixtures/environments.json @@ -0,0 +1,601 @@ +[ + { + "description": "A former druidic grove lying fallow and fully reclaimed by nature.", + "difficulty": "11", + "feature": [ + { + "name": "Overgrown Battlefield - Passive", + "question": "Why did these groups come to blows? Why is the grove unused now?", + "text": "There has been a battle here. A PC can make an Instinct Roll to identify evidence of that fight. On a success with Hope, learn all three pieces of information below. On a success with Fear, learn two. On a failure, a PC can mark a Stress to learn one and gain advantage on the next action roll to investigate this environment. A PC with an appropriate background or Experience can learn an additional detail and ask a follow-up question about the scene and get a truthful (if not always complete) answer. _Why did these groups come to blows? Why is the grove unused now?_\n\n- Traces of a battle (broken weapons and branches, gouges in the ground) litter the ground.\n- A moss-covered tree trunk is actually the corpse of a treant.\n- Still-standing trees are twisted in strange ways, as if by powerful magic." + }, + { + "name": "Barbed Vines - Action", + "question": "How many vines are there? Where do they grab you? Do they pull you down or lift you off the ground?", + "text": "Pick a point within the grove. All targets within Very Close range of that point must succeed on an Agility Reaction Roll or take **1d8+3** physical damage and become _Restrained_ by barbed vines. _Restrained_ lasts until they're freed with a successful Finesse or Strength roll or by dealing at least 6 damage to the vines. _How many vines are there? Where do they grab you? Do they pull you down or lift you off the ground?_" + }, + { + "name": "You Are Not Welcome Here - Action", + "question": "What are the grove guardians concealing? What threat to the forest could the PCs confront to appease the Dryad?", + "text": "A Young Dryad, two Sylvan Soldiers, and a number of Minor Treants equal to the number of PCs appear to confront the party for their intrusion. _What are the grove guardians concealing? What threat to the forest could the PCs confront to appease the Dryad?_" + }, + { + "name": "Defiler - Action", + "question": "What color does the grass turn as the elemental appears? How does the chaos warp insects and small wildlife within the grove?", + "text": "**Spend a Fear** to summon a Minor Chaos Elemental drawn to the echoes of violence and discord. They appear within Far range of a chosen PC and immediately take the spotlight. _What color does the grass turn as the elemental appears? How does the chaos warp insects and small wildlife within the grove?_" + } + ], + "impulses": "Draw in the curious, echo the past", + "name": "Abandoned Grove", + "potential_adversaries": "Beasts (Bear, Dire Wolf, Glass Snake), Grove Guardians (Minor Treant, Sylvan Soldier, Young Dryad)", + "tier": "1", + "type": "Exploration" + }, + { + "description": "An ambush is set to catch an unsuspecting party off-guard.", + "difficulty": "Special (see \"Relative Strength\")", + "feature": [ + { + "name": "Relative Strength - Passive", + "question": "Who cues the ambush? What makes it clear they're in charge?", + "text": "The Difficulty of this environment equals that of the adversary with the highest Difficulty. _Who cues the ambush? What makes it clear they're in charge?_" + }, + { + "name": "Surprise! - Action", + "question": "What do the ambushers want from the party? How do their tactics in the ambush reflect that?", + "text": "The ambushers reveal themselves to the party, you gain 2 Fear, and the spotlight immediately shifts to one of the ambushing adversaries. _What do the ambushers want from the party? How do their tactics in the ambush reflect that?_" + } + ], + "impulses": "Overwhelm, scatter, surround", + "name": "Ambushed", + "potential_adversaries": "Any", + "tier": "1", + "type": "Event" + }, + { + "description": "An ambush is set by the PCs to catch unsuspecting adversaries off-guard.", + "difficulty": "Special (see \"Relative Strength\")", + "feature": [ + { + "name": "Relative Strength - Passive", + "question": "Which adversary is the least prepared? Which one is the most?", + "text": "The Difficulty of this environment equals that of the adversary with the highest Difficulty. _Which adversary is the least prepared? Which one is the most?_" + }, + { + "name": "Where Did They Come From? - Reaction", + "question": "What are the adversaries in the middle of doing when the ambush starts? How does this impact their approach to the fight?", + "text": "When a PC starts the ambush on unsuspecting adversaries, you lose 2 Fear and the first attack roll a PC makes has advantage. _What are the adversaries in the middle of doing when the ambush starts? How does this impact their approach to the fight?_" + } + ], + "impulses": "Escape, group up, protect the most vulnerable", + "name": "Ambushers", + "potential_adversaries": "Any", + "tier": "1", + "type": "Event" + }, + { + "description": "The economic heart of the settlement, with local artisans, traveling merchants, and patrons across social classes.", + "difficulty": "10", + "feature": [ + { + "name": "Tip the Scales - Passive", + "question": "How overt are the PCs in offering this bribe?", + "text": "PCs can gain advantage on a Presence Roll by offering a handful of gold as part of the interaction. _Will any coin be accepted, or only local currency? How overt are the PCs in offering this bribe?_" + }, + { + "name": "Unexpected Find - Action", + "question": "What cost beyond gold will the merchant ask for in exchange for this rarity?", + "text": "Reveal to the PCs that one of the merchants has something they want or need, such as food from their home, a rare book, magical components, a dubious treasure map, or a magical key. _What cost beyond gold will the merchant ask for in exchange for this rarity?_" + }, + { + "name": "Sticky Fingers - Action", + "question": "What drove this person to pickpocketing? Where is the thief's hideout and how has it avoided notice?", + "text": "A thief tries to steal something from a PC. The PC must succeed on an Instinct Roll to notice the thief or lose an item of the GM's choice as the thief escapes to a Close distance. To retrieve the stolen item, the PCs must complete a Progress Countdown (6) to chase down the thief before the thief completes a Consequence Countdown (4) and escapes to their hideout. _What drove this person to pickpocketing? Where is the thief's hideout and how has it avoided notice?_" + }, + { + "name": "Crowd Closes In - Reaction", + "question": "Where does the crowd's movement carry them? How do they feel about being alone but surrounded?", + "text": "When one of the PCs splits from the group, the crowds shift and cut them off from the party. _Where does the crowd's movement carry them? How do they feel about being alone but surrounded?_" + } + ], + "impulses": "Buy low, and sell high, tempt and tantalize with wares from near and far", + "name": "Bustling Marketplace", + "potential_adversaries": "Guards (Bladed Guard, Head Guard), Masked Thief, Merchant", + "tier": "1", + "type": "Social" + }, + { + "description": "A steep, rocky cliff side tall enough to make traversal dangerous.", + "difficulty": "12", + "feature": [ + { + "name": "The Climb - Passive", + "question": "What strange formations are the stones arranged in? What ominous warnings did previous adventurers leave?", + "text": "Climbing up the cliff side uses a Progress Countdown (12). It ticks down according to the following criteria when the PCs make an action roll to climb:\n\n- **Critical Success:** Tick down 3\n- **Success with Hope:** Tick down 2\n- **Success with Fear:** Tick down 1\n- **Failure with Hope:** No advancement\n- **Failure with Fear:** Tick up 1\n\nWhen the countdown triggers, the party has made it to the top of the cliff. _What strange formations are the stones arranged in? What ominous warnings did previous adventurers leave?_" + }, + { + "name": "Pitons Left Behind - Passive", + "question": "What do the shape and material of these pitons tell you about the previous climbers? How far apart are they from one another?", + "text": "Previous climbers left behind large metal rods that climbers can use to aid their ascent. If a PC using the pitons fails an action roll to climb, they can mark a Stress instead of ticking the countdown up. _What do the shape and material of these pitons tell you about the previous climbers? How far apart are they from one another?_" + }, + { + "name": "Fall - Action", + "question": "How can you tell many others have fallen here before? What lives in these walls that might try to scare adventurers into falling for an easy meal?", + "text": "**Spend a Fear** to have a PC's handhold fail, plummeting them toward the ground. If they aren't saved on the next action, they hit the ground and tick up the countdown by 2. The PC takes **1d12** physical damage if the countdown is between 8 and 12, **2d12** between 4 and 7, and **3d12** at 3 or lower. _How can you tell many others have fallen here before? What lives in these walls that might try to scare adventurers into falling for an easy meal?_" + } + ], + "impulses": "Cast the unready down to a rocky doom, draw people in with promise of what lies at the top", + "name": "Cliffside Ascent", + "potential_adversaries": "Construct, Deeproot Defender, Giant Scorpion, Glass Snake", + "tier": "1", + "type": "Traversal" + }, + { + "description": "A lively tavern that serves as the social hub for its town.", + "difficulty": "10", + "feature": [ + { + "name": "What's the Talk of the Town? - Passive", + "question": "Who has what kind of information? What gossip do the locals start spreading about the PCs?", + "text": "A PC can ask the bartender, staff, or patrons about local events, rumors, and potential work with a Presence Roll. On a success, they can pick two of the below details to learn—or three if they critically succeed. On a failure, they can pick one and mark a Stress as the local carries on about something irrelevant. _Who has what kind of information? What gossip do the locals start spreading about the PCs?_\n\n- A fascinating rumor with a connection to a PC's background\n- A promising job for the party involving a nearby threat or situation\n- Local folklore that relates to something they've seen\n- Town gossip that hints at a community problem" + }, + { + "name": "Sing For Your Supper - Passive", + "question": "What piece do you perform? What does that piece mean to you? When's the last time you performed it for a crowd?", + "text": "A PC can perform one time for the guests by making a Presence Roll. On a success, they earn **1d4** handfuls of gold (**2d4** if they critically succeed). On a failure, they mark a Stress. _What piece do you perform? What does that piece mean to you? When's the last time you performed it for a crowd?_" + }, + { + "name": "Mysterious Stranger - Action", + "question": "What do they want? What's their impression of the PCs? What mannerisms or accessories do they have?", + "text": "Reveal a stranger concealing their identity, lurking in a shaded booth. _What do they want? What's their impression of the PCs? What mannerisms or accessories do they have?_" + }, + { + "name": "Someone Comes to Town - Action", + "question": "Did they know the PCs were here? What do they want in this town?", + "text": "Introduce a significant NPC who wants to hire the party for something or who relates to a PC's background. _Did they know the PCs were here? What do they want in this town?_" + }, + { + "name": "Bar Fight! - Action", + "question": "Who started the fight? What will it take to stop it?", + "text": "**Spend a Fear** to have a bar fight erupt in the tavern. When a PC tries to move through the tavern while the fight persists, they must succeed on an Agility or Presence Roll or take **1d6+2** physical damage from a wild swing or thrown object. A PC can try to activate this feature by succeeding on an action roll that would provoke tavern patrons. _Who started the fight? What will it take to stop it?_" + } + ], + "impulses": "Provide opportunities for adventurers, nurture community", + "name": "Local Tavern", + "potential_adversaries": "Guards (Bladed Guard, Head Guard), Mercenaries (Harrier, Sellsword, Spellblade, Weaponmaster), Merchant", + "tier": "1", + "type": "Social" + }, + { + "description": "A small town on the outskirts of a nation or region, close to a dungeon, tombs, or other adventuring destinations.", + "difficulty": "12", + "feature": [ + { + "name": "Rumors Abound - Passive", + "question": "What news do the PCs have that they could pass along to curious travelers? What do the locals think about these events?", + "text": "Gossip is the fastest-traveling currency in the realm. A PC can inquire about major events by making a Presence Roll. What they learn depends on the outcome of their roll, based on the following criteria. _What news do the PCs have that they could pass along to curious travelers? What do the locals think about these events?_\n\n- **Critical Success:** Learn about two major events. The PC can ask one follow-up question about one of the rumors and get a truthful (if not always complete) answer.\n- **Success with Hope:** Learn about two events, at least one of which is relevant to the character's background.\n- **Success with Fear:** Learn an alarming rumor related to the character's background.\n- **Any Failure:** The locals respond poorly to their inquiries. The PC must mark a Stress to learn one relevant rumor." + }, + { + "name": "Society of the Broken Compass - Passive", + "question": "What boasts do the adventurers here make, and which do you think are true?", + "text": "An adventuring society maintains a chapterhouse here, where heroes trade boasts and rumors, drink to their imagined successes, and scheme to undermine their rivals. _What boasts do the adventurers here make, and which do you think are true?_" + }, + { + "name": "Rival Party - Passive", + "question": "Which PC has a connection to one of the rival party members? Do they approach the PC first or do they wait for the PC to move?", + "text": "Another adventuring party is here, seeking the same treasure or leads as the PCs. _Which PC has a connection to one of the rival party members? Do they approach the PC first or do they wait for the PC to move?_" + }, + { + "name": "It'd Be a Shame If Something Happened to Your Store - Action", + "question": "What trouble does it cause if the PCs intervene?", + "text": "The PCs witness as agents of a local crime boss shake down a general goods store. _What trouble does it cause if the PCs intervene?_" + }, + { + "name": "Wrong Place, Wrong Time - Reaction", + "question": "What details show the party that these people are desperate former adventurers?", + "text": "At night, or when the party is alone in a back alley, you can **spend a Fear** to introduce a group of thieves who try to rob them. The thieves appear at Close range of a chosen PC and include a Jagged Knife Kneebreaker, as many Lackeys as there are PCs, and a Lieutenant. For a larger party, add a Hexer or Sniper. _What details show the party that these people are desperate former adventurers?_" + } + ], + "impulses": "Drive the desperate to certain doom, profit off of ragged hope", + "name": "Outpost Town", + "potential_adversaries": "Jagged Knife Bandits (Hexer, Kneebreaker, Lackey, Lieutenant, Shadow, Sniper), Masked Thief, Merchant", + "tier": "1", + "type": "Social" + }, + { + "description": "A swift-moving river without a bridge crossing, deep enough to sweep away most people.", + "difficulty": "10", + "feature": [ + { + "name": "Dangerous Crossing - Passive", + "question": "Are any of them afraid of drowning?", + "text": "Crossing the river requires the party to complete a Progress Countdown (4). A PC who rolls a failure with Fear is immediately targeted by the \"Undertow\" action without requiring a Fear to be spent on the feature. _Have any of the PCs forded rivers like this before? Are any of them afraid of drowning?_" + }, + { + "name": "Undertow - Action", + "question": "What trinkets and baubles lie along the bottom of the riverbed? Do predators swim these rivers?", + "text": "**Spend a Fear** to catch a PC in the undertow. They must make an Agility Reaction Roll. On a failure, they take **1d6+1** physical damage and are moved a Close distance down the river, becoming _Vulnerable_ until they get out of the river. On a success, they must mark a Stress. _What trinkets and baubles lie along the bottom of the riverbed? Do predators swim these rivers?_" + }, + { + "name": "Patient Hunter - Action", + "question": "What treasures does the beast have in their burrow? What travelers have already fallen victim to this predator?", + "text": "**Spend a Fear** to summon a Glass Snake within Close range of a chosen PC. The Snake appears in or near the river and immediately takes the spotlight to use their \"Spinning Serpent\" action. _What treasures does the beast have in their burrow? What travelers have already fallen victim to this predator?_" + } + ], + "impulses": "Bar crossing, carry away the unready, divide the land", + "name": "Raging River", + "potential_adversaries": "Beasts (Bear, Glass Snake), Jagged Knife Bandits (Hexer, Kneebreaker, Lackey, Lieutenant, Shadow, Sniper)", + "tier": "1", + "type": "Traversal" + }, + { + "description": "A Fallen cult assembles around a sigil of the defeated gods and a bonfire that burns a sickly shade of green.", + "difficulty": "14", + "feature": [ + { + "name": "Desecrated Ground - Passive", + "question": "How do the PCs first notice that something is wrong about this place? What fears resurface while hope is kept at bay?", + "text": "Cultists dedicated this place to the Fallen Gods, and their foul influence seeps into it. Reduce the PCs' Hope Die to a **d10** while in this environment. The desecration can be removed with a Progress Countdown (6). _How do the PCs first notice that something is wrong about this place? What fears resurface while hope is kept at bay?_" + }, + { + "name": "Blasphemous Might - Action", + "question": "How does the enemy change in appearance? What fears do their blows bring to the surface?", + "text": "A portion of the ritual's power is diverted into a cult member to fight off interlopers. Choose one adversary to become _Imbued_ with terrible magic until the scene ends or they're defeated. An _Imbued_ adversary immediately takes the spotlight and gains one of the following benefits, or all three if you **spend a Fear**:\n\n- They gain advantage on all attacks.\n- They deal an extra **1d10** damage on a successful attack.\n- They gain the following feature: _Relentless (2) - Passive:_ This adversary can be spotlighted up to two times per GM turn. Spend Fear as usual to spotlight them. _How does the enemy change in appearance? What fears do their blows bring to the surface?_" + }, + { + "name": "The Summoning - Reaction: Countdown (6)", + "question": "What will the cult do with this leashed demon if they succeed? What will they try to summon next?", + "text": "When the PCs enter the scene or the cult begins the ritual to summon a demon, activate the countdown. Designate one adversary to lead the ritual. The countdown ticks down when a PC rolls with Fear. When it triggers, summon a Minor Demon within Very Close range of the ritual's leader. If the leader is defeated, the countdown ends with no effect as the ritual fails. _What will the cult do with this leashed demon if they succeed? What will they try to summon next?_" + }, + { + "name": "Complete the Ritual - Reaction", + "question": "What does it feel like to see such devotion turned to the pursuit of fear and domination?", + "text": "If the ritual's leader is targeted by an attack or spell, an ally within Very Close range of them can **mark a Stress** to be targeted by that attack or spell instead. _What does it feel like to see such devotion turned to the pursuit of fear and domination?_" + } + ], + "impulses": "Profane the land, unite the Mortal Realm with the Circles Below", + "name": "Cult Ritual", + "potential_adversaries": "Cult of the Fallen (Cult Adept, Cult Fang, Cult Initiate, Secret-Keeper)", + "tier": "2", + "type": "Event" + }, + { + "description": "A bustling but well-kept temple that provides healing and hosts regular services, overseen by a priest or seraph.", + "difficulty": "13", + "feature": [ + { + "name": "A Place of Healing - Passive", + "question": "What does the incense smell like? What kinds of songs do the acolytes sing?", + "text": "A PC who takes a rest in the Hallowed Temple automatically clears all HP. _What does the incense smell like? What kinds of songs do the acolytes sing?_" + }, + { + "name": "Divine Guidance - Passive", + "question": "What does it feel like as you are touched by this vision? What feeling lingers after the images have passed?", + "text": "A PC who prays to a deity while in the Hallowed Temple can make an Instinct Roll to receive answers. If the god they beseech isn't welcome in this temple, the roll is made with disadvantage. _What does it feel like as you are touched by this vision? What feeling lingers after the images have passed?_\n\n- **Critical Success:** The PC gains clear information. Additionally, they gain **1d4** Hope, which can be distributed between the party if they share the vision and guidance they received.\n- **Success with Hope:** The PC receives clear information.\n- **Success with Fear:** The PC receives brief flashes of insight and an emotional impression conveying an answer.\n- **Any Failure:** The PC receives only vague flashes. They can mark a Stress to receive one clear image without context." + }, + { + "name": "Relentless Hope - Reaction", + "question": "What emotions or memories do you connect with when fear presses in?", + "text": "Once per scene, each PC can mark a Stress to turn a result with Fear into a result with Hope. _What emotions or memories do you connect with when fear presses in?_" + }, + { + "name": "Divine Censure - Reaction", + "question": "What symbols or icons do they bear that signal they are anointed agents of the divinity? Who leads the group and what led them to this calling?", + "text": "When the PCs have trespassed, blasphemed, or off ended the clergy, you can **spend a Fear** to summon a High Seraph and **1d4** Bladed Guards within Close range of the senior priest to reinforce their will. _What symbols or icons do they bear that signal they are anointed agents of the divinity? Who leads the group and what led them to this calling?_" + } + ], + "impulses": "Connect the Mortal Realm with the Hallows Above, display the power of the divine, provide aid and succor to the faithful", + "name": "Hallowed Temple", + "potential_adversaries": "Guards (Archer Guard, Bladed Guard, Head Guard)", + "tier": "2", + "type": "Social" + }, + { + "description": "An abandoned city populated by the restless spirits of eras past.", + "difficulty": "14", + "feature": [ + { + "name": "Buried Knowledge - Passive", + "question": "What greater secrets does the city contain? Why have so many ghosts lingered here? What doomed adventurers have met a bad fate here already?", + "text": "The city has countless mysteries to unfold. A PC who seeks knowledge about the fallen city can make an Instinct or Knowledge Roll to learn about this place and discover (potentially haunted) loot. _What greater secrets does the city contain? Why have so many ghosts lingered here? What doomed adventurers have met a bad fate here already?_\n\n- **Critical Success:** Gain valuable information and a related useful item.\n- **Success with Hope:** Gain valuable information.\n- **Success with Fear:** Uncover vague or incomplete information.\n- **Any Failure:** Mark a Stress to find a lead after an exhaustive search." + }, + { + "name": "Ghostly Form - Passive", + "question": "What injuries to their physical form speak to their cause of death? What unfulfilled purpose holds them in the Mortal Plane?", + "text": "Adversaries who appear here are of a ghostly form. They have resistance to physical damage and can **mark a Stress** to move up to Close range through solid objects. _What injuries to their physical form speak to their cause of death? What unfulfilled purpose holds them in the Mortal Plane?_" + }, + { + "name": "Dead Ends - Action", + "question": "What do the ghosts want from you? What do you need from them?", + "text": "The ghosts of an earlier era manifest scenes from their bygone era, such as a street festival, a revolution, or a heist. These hauntings change the layout of the city around the PCs, blocking the way behind them, forcing a detour, or presenting them with a challenge, such as mistaking them for rival thieves during the heist. _What do the ghosts want from you? What do you need from them?_" + }, + { + "name": "Apocalypse Then - Action", + "question": "Is this the disaster that led the city to be abandoned? What is known about this disaster, and how could that help the PCs escape?", + "text": "**Spend a Fear** to manifest the echo of a past disaster that ravaged the city. Activate a Progress Countdown (5) as the disaster replays around the PCs. To complete the countdown and escape the catastrophe, the PCs must overcome threats such as rampaging fires, stampeding civilians, collapsing buildings, or crumbling streets, while recalling history and finding clues to escape the inevitable. _Is this the disaster that led the city to be abandoned? What is known about this disaster, and how could that help the PCs escape?_" + } + ], + "impulses": "Misdirect and disorient, replay apocalypses both public and personal", + "name": "Haunted City", + "potential_adversaries": "Ghosts (Spectral Archer, Spectral Captain, Spectral Guardian), ghostly versions of other adversaries (see \"Ghostly Form\")", + "tier": "2", + "type": "Exploration" + }, + { + "description": "Stony peaks that pierce the clouds, with a twisting path winding its way up and over through many switchbacks.", + "difficulty": "15", + "feature": [ + { + "name": "Engraved Sigils - Passive", + "question": "Who laid this enchantment? Are they nearby? Why did they want the weather to be more daunting?", + "text": "Large markings and engravings have been made in the mountainside. A PC with a relevant background or Experience identifies them as weather magic increasing the power of the icy winds. A PC who succeeds on a Knowledge Roll can recall information about the sigils, potential information about their creators, and the knowledge of how to dispel them. If a PC critically succeeds, they recognize that the sigils are of a style created by ridgeborne enchanters and they gain advantage on a roll to dispel the sigils. _Who laid this enchantment? Are they nearby? Why did they want the weather to be more daunting?_" + }, + { + "name": "Avalanche - Action", + "question": "How do the PCs try to weather the avalanche? What approach do the characters take to find one another when their companions go hurtling down the mountainside?", + "text": "**Spend a Fear** to carve the mountain with an icy torrent, causing an avalanche. All PCs in its path must succeed on an Agility or Strength Reaction Roll or be bowled over and carried down the mountain. A PC using rope, pitons, or other climbing gear gains advantage on this roll. Targets who fail are knocked down the mountain to Far range, take **2d20** physical damage, and must mark a Stress. Targets who succeed must mark a Stress. _How do the PCs try to weather the avalanche? What approach do the characters take to find one another when their companions go hurtling down the mountainside?_" + }, + { + "name": "Raptor Nest - Reaction", + "question": "How long has it been since the eagles last found prey? Do they have eggs in their nest, or unfledged young?", + "text": "When the PCs enter the raptors' hunting grounds, two Giant Eagles appear at Very Far range of a chosen PC, identifying the PCs as likely prey. _How long has it been since the eagles last found prey? Do they have eggs in their nest, or unfledged young?_" + }, + { + "name": "Icy Winds - Reaction: Countdown (Loop 4)", + "question": "What parts of the PC's bodies go numb first? How do they try to keep warm as they press forward?", + "text": "When the PCs enter the mountain pass, activate the countdown. When it triggers, all characters traveling through the pass must succeed on a Strength Reaction Roll or mark a Stress. A PC wearing clothes appropriate for extreme cold gains advantage on these rolls. _What parts of the PC's bodies go numb first? How do they try to keep warm as they press forward?_" + } + ], + "impulses": "Exact a chilling toll in supplies and stamina, reveal magical tampering, slow down travel", + "name": "Mountain Pass", + "potential_adversaries": "Beasts (Bear, Giant Eagle, Glass Snake), Chaos Skull, Minotaur Wrecker, Mortal Hunter", + "tier": "2", + "type": "Traversal" + }, + { + "description": "Thick indigo ash fills the air around a towering moss-covered tree that burns eternally with flames a sickly shade of blue.", + "difficulty": "16", + "feature": [ + { + "name": "Chaos Magic Locus - Passive", + "question": "What does it feel like to work magic in this chaos-touched place? What do you fear will happen if you lose control of the spell?", + "text": "When a PC makes a Spellcast Roll, they must roll two Fear Dice and take the higher result. _What does it feel like to work magic in this chaos-touched place? What do you fear will happen if you lose control of the spell?_" + }, + { + "name": "The Indigo Flame - Passive", + "question": "What Fallen cult corrupted these woods? What have they already done with the cursed wood and sap from this tree?", + "text": "PCs who approach the central tree can make a Knowledge Roll to try to identify the magic that consumed this environment. _What Fallen cult corrupted these woods? What have they already done with the cursed wood and sap from this tree?_\n\n- **On a success**: They learn three of the below details. On a success with Fear, they learn two.\n- **On a failure**: They can mark a Stress to learn one and gain advantage on the next action roll to investigate this environment.\n- **Details:** This is a result of Fallen magic. The corruption is spread through the ashen moss. It can be cleansed only by a ritual of nature magic with a Progress Countdown (8)." + }, + { + "name": "Grasping Vines - Action", + "question": "What painful memories do the vines bring to the surface as they pierce flesh?", + "text": "Animate vines bristling with thorns whip out from the underbrush to ensnare the PCs. A target must succeed on an Agility Reaction Roll or become _Restrained_ and _Vulnerable_ until they break free, clearing both conditions, with a successful Finesse or Strength Roll or by dealing 10 damage to the vines. When the target makes a roll to escape, they take **1d8+4** physical damage and lose a Hope. _What painful memories do the vines bring to the surface as they pierce flesh?_" + }, + { + "name": "Charcoal Constructs - Action", + "question": "Are these real animals consumed by the flame or merely constructs of the corrupting magic?", + "text": "Warped animals wreathed in indigo flame trample through a point of your choice. All targets within Close range of that point must make an Agility Reaction Roll. Targets who fail take **3d12+3** physical damage. Targets who succeed take half damage instead. _Are these real animals consumed by the flame or merely constructs of the corrupting magic?_" + }, + { + "name": "Choking Ash - Reaction: Countdown (Loop 6)", + "question": "What hallucinations does the ash induce? What incongruous taste does it possess?", + "text": "When the PCs enter the Burning Heart of the Woods, activate the countdown. When it triggers, all characters must make a Strength or Instinct Reaction Roll. Targets who fail take **4d6+5** direct physical damage. Targets who succeed take half damage. Protective masks or clothes give advantage on the reaction roll. _What hallucinations does the ash induce? What incongruous taste does it possess?_" + } + ], + "impulses": "Beat out an uncanny rhythm for all to follow, corrupt the woods", + "name": "Burning Heart of the Woods", + "potential_adversaries": "Beasts (Bear, Glass Snake), Elementals (Elemental Spark), Verdant Defenders (Dryad, Oak Treant, Stag Knight)", + "tier": "3", + "type": "Exploration" + }, + { + "description": "An active siege with an attacking force fighting to gain entry to a fortified castle.", + "difficulty": "17", + "feature": [ + { + "name": "Secret Entrance - Passive", + "question": "How do they get in without revealing the pathway to the attackers? Are any of the defenders monitoring this path?", + "text": "A PC can find or recall a secret way into the castle with a successful Instinct or Knowledge Roll. _How do they get in without revealing the pathway to the attackers? Are any of the defenders monitoring this path?_" + }, + { + "name": "Siege Weapons (Environment Change) - Action", + "question": "What siege weapons are being deployed? Are they magical, mundane, or a mixture of both? What defenses must the characters overcome to storm the castle?", + "text": "_Consequence Countdown (6)._ The attacking force deploys siege weapons to try to raze the defenders' fortifications. Activate the countdown when the siege begins (for a protracted siege, make this a long-term countdown instead). When it triggers, the defenders' fortifications have been breached and the attackers flood inside. You gain 2 Fear, then shift to the Pitched Battle environment and spotlight it. _What siege weapons are being deployed? Are they magical, mundane, or a mixture of both? What defenses must the characters overcome to storm the castle?_" + }, + { + "name": "Reinforcements! - Action", + "question": "Who are they targeting first? What formation do they take?", + "text": "Summon a Knight of the Realm, a number of Tier 3 Minions equal to the number of PCs, and two adversaries of your choice within Far range of a chosen PC as reinforcements. The Knight of the Realm immediately takes the spotlight. _Who are they targeting first? What formation do they take?_" + }, + { + "name": "Collateral Damage - Reaction", + "question": "What debris is scattered by the attack? What is broken by the strike that can't be easily mended?", + "text": "When an adversary is defeated, you can **spend a Fear** to have a stray attack from a siege weapon hit a point on the battlefield. All targets within Very Close range of that point must make an Agility Reaction Roll. _What debris is scattered by the attack? What is broken by the strike that can't be easily mended?_\n\n- Targets who fail take **3d8+3** physical or magic damage and must mark a Stress.\n- Targets who succeed must mark a Stress." + } + ], + "impulses": "Bleed out the will to fight, breach the walls, build tension", + "name": "Castle Siege", + "potential_adversaries": "Mercenaries (Harrier, Sellsword, Spellblade, Weaponmaster), Noble Forces (Archer Squadron, Conscript, Elite Soldier, Knight of the Realm)", + "tier": "3", + "type": "Event" + }, + { + "description": "A massive combat between two large groups of armed combatants.", + "difficulty": "17", + "feature": [ + { + "name": "Adrift on a Sea of Steel - Passive", + "question": "Do the combatants mistake you for the enemy or consider you interlopers? Can you tell the difference between friend and foe in the fray?", + "text": "Traversing a battlefield during an active combat is extremely dangerous. A PC must succeed on an Agility Roll to move at all, and can only go up to Close range on a success. If an adversary is within Melee range of them, they must mark a Stress to make an Agility Roll to move. _Do the combatants mistake you for the enemy or consider you interlopers? Can you tell the difference between friend and foe in the fray?_" + }, + { + "name": "Raze and Pillage - Action", + "question": "What is valuable here? Who is most vulnerable?", + "text": "The attacking force raises the stakes by lighting a fire, stealing a valuable asset, kidnapping an important person, or killing the populace. _What is valuable here? Who is most vulnerable?_" + }, + { + "name": "War Magic - Action", + "question": "What form does the attack take—fireball, raining acid, a storm of blades? What tactical objective is this attack meant to accomplish, and what comes next?", + "text": "**Spend a Fear** as a mage from one side uses large-scale destructive magic. Pick a point on the battlefield within Very Far range of the mage. All targets within Close range of that point must make an Agility Reaction Roll. Targets who fail take **3d12+8** magic damage and must mark a Stress. _What form does the attack take—fireball, raining acid, a storm of blades? What tactical objective is this attack meant to accomplish, and what comes next?_" + }, + { + "name": "Reinforcements! - Action", + "question": "Who are they targeting first? What formation do they take?", + "text": "Summon a Knight of the Realm, a number of Tier 3 Minions equal to the number of PCs, and two adversaries of your choice within Far range of a chosen PC as reinforcements. The Knight of the Realm immediately takes the spotlight. _Who are they targeting first? What formation do they take?_" + } + ], + "impulses": "Seize people, land, and wealth, spill blood for greed and glory", + "name": "Pitched Battle", + "potential_adversaries": "Mercenaries (Sellsword, Harrier, Spellblade, Weaponmaster), Noble Forces (Archer Squadron, Conscript, Elite Soldier, Knight of the Realm)", + "tier": "3", + "type": "Event" + }, + { + "description": "An otherworldly space where the laws of reality are unstable and dangerous.", + "difficulty": "20", + "feature": [ + { + "name": "Impossible Architecture - Passive", + "question": "What does it feel like to move in a space so alien to the Mortal Realm? What landmark or point do you fixate on to maintain your balance? What bizarre landmarks do you traverse on your journey?", + "text": "Up is down, down is right, right is starward. Gravity and directionality themselves are in flux, and any attempt to move through this realm is an odyssey unto itself, requiring a Progress Countdown (8). On a failure, a PC must mark a Stress in addition to the roll's other consequences. _What does it feel like to move in a space so alien to the Mortal Realm? What landmark or point do you fixate on to maintain your balance? What bizarre landmarks do you traverse on your journey?_" + }, + { + "name": "Everything You Are This Place Will Take from You - Action: Countdown (Loop 1d4)", + "question": "How does this place try to steal from you that which makes you legendary? What does it feel like to have this power taken from you?", + "text": "Activate the countdown. When it triggers, all PCs must succeed on a Presence Reaction Roll or their highest trait is temporarily reduced by **1d4** unless they mark a number of Stress equal to its value. Any lost trait points are regained if the PC critically succeeds or escapes the Chaos Realm. _How does this place try to steal from you that which makes you legendary? What does it feel like to have this power taken from you?_" + }, + { + "name": "Unmaking - Action", + "question": "What glimpse of other worlds do you catch while this place tries to unmake you? What core facet of your personality does the unmaking try to erase?", + "text": "**Spend a Fear** to force a PC to make a Strength Reaction Roll. On a failure, they take **4d10** direct magic damage. On a success, they must mark a Stress. _What glimpse of other worlds do you catch while this place tries to unmake you? What core facet of your personality does the unmaking try to erase?_" + }, + { + "name": "Outer Realms Predators - Action", + "question": "What half-consumed remnants of the shattered world do these monstrosities cast aside in pursuit of living flesh? What jagged reflections of former personhood do you catch between moments of unquestioning malice?", + "text": "**Spend a Fear** to summon an Outer Realms Abomination, an Outer Realms Corruptor, and **2d6** Outer Realms Thralls, who appear at Close range of a chosen PC in defiance of logic and causality. Immediately spotlight one of these adversaries, and you can **spend an additional Fear** to automatically succeed on that adversary's standard attack. _What half-consumed remnants of the shattered world do these monstrosities cast aside in pursuit of living flesh? What jagged reflections of former personhood do you catch between moments of unquestioning malice?_" + }, + { + "name": "Disorienting Reality - Reaction", + "question": "What moment do they see? How hard will it be to hold on to the real memory?", + "text": "On a result with Fear, you can ask the PC to describe which of their fears the Chaos Realm evokes as a vision of reality unmakes and reconstitutes itself to the PC. The PC loses a Hope. If it is their last Hope, you gain a Fear. _What moment do they see? If it's a memory, how is it warped by this place? How hard will it be to hold on to the real memory?_" + } + ], + "impulses": "Annihilate certainty, consume power, defy logic", + "name": "Chaos Realm", + "potential_adversaries": "Outer Realms Monstrosities (Abomination, Corruptor, Thrall)", + "tier": "4", + "type": "Traversal" + }, + { + "description": "A massive ritual designed to breach the gates of the Hallows Above and unseat the New Gods themselves.", + "difficulty": "20", + "feature": [ + { + "name": "Final Preparations - Passive", + "question": "What does the Usurper still require: The heart of a High Seraph?", + "text": "When the environment first takes the spotlight, designate one adversary as the Usurper seeking to overthrow the gods. Activate a Long-Term Countdown (8) as the Usurper assembles what they need to conduct the ritual. When it triggers, spotlight this environment to use the \"Beginning of the End\" feature. While this environment remains in play, you can hold up to 15 Fear. _What does the Usurper still require: The heart of a High Seraph? The lodestone of an ancient waygate? The loyalty of two archenemies? The heartbroken tears of a pure soul?_" + }, + { + "name": "Divine Blessing - Passive", + "question": "What god favors you as you fight against this usurpation? How does your renewed power reflect their influence?", + "text": "When a PC critically succeeds, they can spend 2 Hope to refresh an ability normally limited by uses (such as once per rest, once per session). _What god favors you as you fight against this usurpation? How does your renewed power reflect their influence?_" + }, + { + "name": "Defilers Abound - Action", + "question": "Which High Fallen do these troops serve? Which god's flesh do they wish to feast upon?", + "text": "**Spend 2 Fear** to summon **1d4+2** Fallen Shock Troops that appear within Close range of the Usurper to assist their divine siege. Immediately spotlight the Shock Troops to use a \"Group Attack\" action. _Which High Fallen do these troops serve? Which god's flesh do they wish to feast upon?_" + }, + { + "name": "Godslayer - Action", + "question": "Which god meets their end? What are their last words? How does the Usurper's new stolen power manifest?", + "text": "If the Divine Siege Countdown (see \"Beginning of the End\") has triggered, you can **spend 3 Fear** to describe the Usurper slaying one of the gods of the Hallows Above, feasting upon their power and growing stronger. The Usurper clears 2 HP. Increase their Difficulty, damage, attack modifier, or give them a new feature from the slain god. _Which god meets their end? What are their last words? How does the Usurper's new stolen power manifest?_" + }, + { + "name": "Beginning of the End - Reaction", + "question": "How does the Mortal Realm writhe as the natural order is violated? What mortals witness this blasphemy from afar?", + "text": "When the \"Final Preparations\" long-term countdown triggers, the Usurper begins hammering on the gates of the Hallows themselves. Activate a Divine Siege Countdown (10). Spotlight the Usurper to describe the Usurper's assault and tick down this countdown by 1. If the Usurper takes Major or greater damage, tick up the countdown by 1. When it triggers, the Usurper shatters the barrier between the Mortal Realm and the Hallows Above to slay the gods and take their place. You gain a Fear for each unmarked HP the Usurper has. You can immediately use the \"Godslayer\" feature without spending Fear to make an additional GM move. _How does the Mortal Realm writhe as the natural order is violated? What mortals witness this blasphemy from afar?_" + }, + { + "name": "Ritual Nexus - Reaction", + "question": "What visions of failures past torment you as your eff orts fall short? How are these memories twisted by the Usurper?", + "text": "On any failure with Fear against the Usurper, the PC must mark **1d4** Stress from the backlash of magical power. _What visions of failures past torment you as your eff orts fall short? How are these memories twisted by the Usurper?_" + } + ], + "impulses": "Collect power, overawe, silence dissent", + "name": "Divine Usurpation", + "potential_adversaries": "Arch-Necromancer, Fallen Shock Troops, Mortal Hunter, Oracle of Doom, Perfected Zombie", + "tier": "4", + "type": "Event" + }, + { + "description": "The majestic domain of a powerful empire, lavishly appointed with stolen treasures.", + "difficulty": "20", + "feature": [ + { + "name": "All Roads Lead Here - Passive", + "question": "How does the way language is used make even discussing alternative ways of living difficult? What obvious benefits for loyalty create friction when you try to discuss alternatives?", + "text": "While in the Imperial Court, a PC has disadvantage on Presence Rolls made to take actions that don't fit the imperial way of life or support the empire's dominance. _How does the way language is used make even discussing alternative ways of living difficult? What obvious benefits for loyalty create friction when you try to discuss alternatives?_" + }, + { + "name": "Rival Vassals - Passive", + "question": "How do they benefit from vassalage, and what has it cost them? What exploitation drives them to consider opposing the unstoppable?", + "text": "The PCs can find imperial subjects, vassals, and supplicants in the court, each vying for favor, seeking proximity to power, exchanging favors for loyalty, and elevating their status above others'. Some might be desperate to undermine their rivals, while others might even be open to discussions that verge on sedition. _How do they benefit from vassalage, and what has it cost them? What exploitation drives them to consider opposing the unstoppable?_" + }, + { + "name": "The Gravity of Empire - Action", + "question": "What do the PCs want so desperately they might consider throwing in with this ruthless power? How did imperial agents learn the PC's greatest desires?", + "text": "**Spend a Fear** to present a PC with a golden opportunity or offer to satisfy a major goal in exchange for obeying or supporting the empire. The target must make a Presence Reaction Roll. On a failure, they must mark all their Stress or accept the offer. If they have already marked all their Stress, they must accept the offer or exile themselves from the empire. On a success, they must mark **1d4** Stress as they're taxed by temptation. _What do the PCs want so desperately they might consider throwing in with this ruthless power? How did imperial agents learn the PC's greatest desires?_" + }, + { + "name": "Imperial Decree - Action", + "question": "What display of power or transfer of wealth was needed to expedite this plan?", + "text": "**Spend a Fear** to tick down a long-term countdown related to the empire's agenda by **1d4**. If this triggers the countdown, a proclamation related to the agenda is announced at court as the plan is executed. _What display of power or transfer of wealth was needed to expedite this plan? Whose lives were disrupted or upended to make this happen?_" + }, + { + "name": "Eyes Everywhere - Reaction", + "question": "How has the empire compromised this witness? Why is their first impulse to protect the empire, even if doesn't treat them well?", + "text": "On a result with Fear, you can **spend a Fear** to have someone loyal to the empire overhear seditious talk within the court. A PC must succeed on an Instinct Reaction Roll to notice that the group has been overheard so they can try to intercept the witness before the PCs are exposed. _How has the empire compromised this witness? Why is their first impulse to protect the empire, even if doesn't treat them well?_" + } + ], + "impulses": "Justify and perpetuate imperial rule, seduce rivals with promises of power and comfort", + "name": "Imperial Court", + "potential_adversaries": "Bladed Guard, Courtesan, Knight of the Realm, Monarch, Spy", + "tier": "4", + "type": "Social" + }, + { + "description": "A dusty crypt with a library, twisting corridors, and abundant sarcophagi, spattered with the blood of ill-fated invaders.", + "difficulty": "19", + "feature": [ + { + "name": "No Place for the Living - Passive", + "question": "What does it feel like to try to heal in a place so antithetical to life?", + "text": "A feature or action that clears HP requires spending a Hope to use. If it already costs Hope, a PC must spend an additional Hope. _What does it feel like to try to heal in a place so antithetical to life?_" + }, + { + "name": "Centuries of Knowledge - Passive", + "question": "What are the names of the tomes? What project is the necromancer working on and what does it communicate about their plans?", + "text": "A PC can investigate the library and laboratory and make a Knowledge Roll to learn information related to arcana, local history, and the Necromancer's plans. _What are the names of the tomes? What project is the necromancer working on and what does it communicate about their plans?_" + }, + { + "name": "Skeletal Burst - Action", + "question": "What ancient skeletal architecture is destroyed? What bones stick in your armor?", + "text": "All targets within Close range of a point you choose in this environment must succeed on an Agility Reaction Roll or take **4d8+8** physical damage from skeletal shrapnel as part of the ossuary detonates around them. _What ancient skeletal architecture is destroyed? What bones stick in your armor?_" + }, + { + "name": "Aura of Death - Action", + "question": "How does their renewed vigor manifest? Do they look more lifelike or, paradoxically, are they more decayed but vigorous?", + "text": "Once per scene, roll a **d4**. Each undead within Far range of the Necromancer can clear HP and Stress equal to the result rolled. The undead can choose how that number is divided between HP and Stress. _How does their renewed vigor manifest? Do they look more lifelike or, paradoxically, are they more decayed but vigorous?_" + }, + { + "name": "They Just Keep Coming! - Action", + "question": "Who were these people before they became the necromancer's pawns? What vestiges of those lives remain for the heroes to see?", + "text": "**Spend a Fear** to summon **1d6** Rotted Zombies, two Perfected Zombies, or a Zombie Legion, who appear at Close range of a chosen PC. _Who were these people before they became the necromancer's pawns? What vestiges of those lives remain for the heroes to see?_" + } + ], + "impulses": "Confound intruders, delve into secrets best left buried, manifest unlife, unleash a tide of undead", + "name": "Necromancer's Ossuary", + "potential_adversaries": "Arch-Necromancer's Host (Perfected Zombie, Zombie Legion)", + "tier": "4", + "type": "Exploration" + } +] \ No newline at end of file diff --git a/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/Fixtures/sample-homebrew.dhpack b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/Fixtures/sample-homebrew.dhpack new file mode 100644 index 0000000..3c6fd9b --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/Fixtures/sample-homebrew.dhpack @@ -0,0 +1,59 @@ +{ + "adversaries": [ + { + "id": "test-goblin", + "name": "Test Goblin", + "source": "homebrew-sample", + "tier": 1, + "type": "Minion", + "description": "A small green creature used for testing.", + "motives_and_tactics": "Swarms in numbers; flees when outnumbered.", + "difficulty": 10, + "thresholds": "6/12", + "hp": 5, + "stress": 2, + "atk": "+1", + "attack": "Rusty Dagger", + "range": "Very Close", + "damage": "1d6 phy", + "feats": [ + { + "name": "Pack Courage", + "text": "While 2+ allies are within Very Close range, this creature is immune to fear.", + "feat_type": "passive" + } + ] + }, + { + "id": "test-troll", + "name": "Test Troll", + "source": "homebrew-sample", + "tier": 2, + "type": "Bruiser", + "description": "A hulking regenerating beast used for testing.", + "difficulty": 14, + "thresholds": "12/20", + "hp": 30, + "stress": 4, + "atk": "+4", + "attack": "Club", + "range": "Close", + "damage": "2d10+3 phy" + } + ], + "environments": [ + { + "id": "test-dungeon", + "name": "Test Dungeon", + "source": "homebrew-sample", + "description": "A damp stone chamber used for testing.", + "feats": [ + { + "name": "Collapsing Ceiling", + "text": "At the start of each round, the GM may deal 1d4 phy damage to one target.", + "feat_type": "action" + } + ] + } + ] +} diff --git a/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/ModelTests.swift b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/ModelTests.swift new file mode 100644 index 0000000..2de65f9 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/ModelTests.swift @@ -0,0 +1,498 @@ +// +// ModelTests.swift +// DaggerheartModelsTests +// +// Unit tests for pure Codable model types: +// Adversary, Condition, PlayerSlot, EncounterDefinition, +// DifficultyBudget, EncounterStoreError, DaggerheartEnvironment. +// + +import Foundation +import Testing + +@testable import DaggerheartModels + +// MARK: - Adversary Decoding + +struct AdversaryDecodingTests { + + // MARK: Combined threshold string ("8/15") + + @Test func decodesThresholdsFromCombinedString() throws { + let json = """ + { + "id": "test-creature", + "name": "Test Creature", + "source": "SRD", + "tier": 1, + "type": "Bruiser", + "description": "A test creature.", + "difficulty": 12, + "thresholds": "8/15", + "hp": 8, + "stress": 3, + "atk": "+3", + "attack": "Claws", + "range": "Very Close", + "damage": "1d10+2 phy", + "feature": [] + } + """.data(using: .utf8)! + + let adversary = try JSONDecoder().decode(Adversary.self, from: json) + #expect(adversary.thresholdMajor == 8) + #expect(adversary.thresholdSevere == 15) + } + + // MARK: Pre-split threshold keys + + @Test func decodesThresholdsFromSplitKeys() throws { + let json = """ + { + "id": "warlord-keth", + "name": "Warlord Keth", + "tier": 2, + "type": "Leader", + "description": "A scarred half-giant general.", + "difficulty": 14, + "threshold_major": 9, + "threshold_severe": 17, + "hp": 12, + "stress": 6, + "atk": "+5", + "attack": "Greataxe", + "range": "Very Close", + "damage": "2d10+4 phy", + "feature": [] + } + """.data(using: .utf8)! + + let adversary = try JSONDecoder().decode(Adversary.self, from: json) + #expect(adversary.thresholdMajor == 9) + #expect(adversary.thresholdSevere == 17) + #expect(adversary.source == "srd") // absent in JSON → default "srd" (lowercased) + } + + // MARK: Feature decoding + + @Test func decodesFeatures() throws { + let json = """ + { + "id": "test", + "name": "Test", + "tier": 1, + "type": "Minion", + "description": "Desc", + "difficulty": 9, + "thresholds": "3/6", + "hp": 3, + "stress": 2, + "atk": "+1", + "attack": "Bite", + "range": "Very Close", + "damage": "1d6 phy", + "feature": [ + { "name": "Pack Tactics", "text": "Deals bonus damage in groups.", "feat_type": "passive" }, + { "name": "Snap", "text": "Triggers when hit.", "feat_type": "reaction" }, + { "name": "Lunge", "text": "Extra attack once per round.", "feat_type": "action" } + ] + } + """.data(using: .utf8)! + + let adversary = try JSONDecoder().decode(Adversary.self, from: json) + #expect(adversary.features.count == 3) + #expect(adversary.features[0].featType == FeatureType.passive) + #expect(adversary.features[1].featType == FeatureType.reaction) + #expect(adversary.features[2].featType == FeatureType.action) + } + + // MARK: AdversaryType round-trip + + @Test func adversaryTypeRoundTrip() throws { + for type_ in AdversaryType.allCases { + let encoded = try JSONEncoder().encode(type_) + let decoded = try JSONDecoder().decode(AdversaryType.self, from: encoded) + #expect(decoded == type_) + } + } + + // MARK: AttackRange round-trip + + @Test func attackRangeRoundTrip() throws { + for range in AttackRange.allCases { + let encoded = try JSONEncoder().encode(range) + let decoded = try JSONDecoder().decode(AttackRange.self, from: encoded) + #expect(decoded == range) + } + } + + // MARK: New SRD adversary types (Skulk, Social, Support) + + @Test func decodesNewAdversaryTypes() throws { + for typeString in ["Skulk", "Social", "Support"] { + let json = """ + { + "id": "test-\(typeString.lowercased())", + "name": "Test \(typeString)", + "tier": 1, + "type": "\(typeString)", + "description": "A test creature.", + "difficulty": 10, + "thresholds": "5/10", + "hp": 4, + "stress": 2, + "atk": "+2", + "attack": "Strike", + "range": "Close", + "damage": "1d6 phy", + "feature": [] + } + """.data(using: .utf8)! + + let adversary = try JSONDecoder().decode(Adversary.self, from: json) + #expect(adversary.type.rawValue == typeString) + } + } + + @Test func adversaryTypeHasAllTenCases() { + #expect(AdversaryType.allCases.count == 10) + } + + // MARK: Malformed threshold throws + + @Test func malformedThresholdStringThrows() { + let json = """ + { + "id": "bad", + "name": "Bad", + "tier": 1, + "type": "Minion", + "description": "Desc", + "difficulty": 9, + "thresholds": "notanumber", + "hp": 3, + "stress": 2, + "atk": "+1", + "attack": "Bite", + "range": "Close", + "damage": "1d6 phy", + "feature": [] + } + """.data(using: .utf8)! + + #expect(throws: (any Error).self) { + try JSONDecoder().decode(Adversary.self, from: json) + } + } +} + +// MARK: - Condition + +struct ConditionTests { + + @Test func standardConditionsExist() { + let conditions: Set = [.hidden, .restrained, .vulnerable] + #expect(conditions.count == 3) + } + + @Test func customConditionEquality() { + let c1 = Condition.custom("Enraged") + let c2 = Condition.custom("Enraged") + let c3 = Condition.custom("Prone") + #expect(c1 == c2) + #expect(c1 != c3) + } + + @Test func conditionSetPreventsStacking() { + var conditions: Set = [] + conditions.insert(.hidden) + conditions.insert(.hidden) + #expect(conditions.count == 1) + } + + @Test func conditionCodableRoundTrip() throws { + let original: Set = [.hidden, .restrained, .custom("Enraged")] + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(Set.self, from: data) + #expect(decoded == original) + } + + @Test func conditionDisplayName() { + #expect(Condition.hidden.displayName == "Hidden") + #expect(Condition.restrained.displayName == "Restrained") + #expect(Condition.vulnerable.displayName == "Vulnerable") + #expect(Condition.custom("Enraged").displayName == "Enraged") + } +} + +// MARK: - PlayerSlot + +struct PlayerSlotTests { + + @Test func playerSlotInitializesWithCorrectDefaults() { + let slot = PlayerSlot( + name: "Aldric", + maxHP: 6, + maxStress: 6, + evasion: 12, + thresholdMajor: 8, + thresholdSevere: 15, + armorSlots: 3 + ) + #expect(slot.name == "Aldric") + #expect(slot.currentHP == 6) + #expect(slot.currentStress == 0) + #expect(slot.currentArmorSlots == 3) + #expect(slot.conditions.isEmpty) + } + + @Test func playerSlotEquality() { + let id = UUID() + let slot1 = PlayerSlot( + id: id, name: "A", maxHP: 6, maxStress: 6, + evasion: 10, thresholdMajor: 5, thresholdSevere: 10, armorSlots: 2 + ) + let slot2 = PlayerSlot( + id: id, name: "A", maxHP: 6, maxStress: 6, + evasion: 10, thresholdMajor: 5, thresholdSevere: 10, armorSlots: 2 + ) + #expect(slot1 == slot2) + } +} + +// MARK: - EncounterDefinition + +struct EncounterDefinitionTests { + + @Test func definitionIsValueType() { + var def1 = EncounterDefinition(name: "Test") + let def2 = def1 + def1.name = "Modified" + #expect(def2.name == "Test") + } + + @Test func definitionCodableRoundTrip() throws { + var definition = EncounterDefinition(name: "Bandit Ambush") + definition.adversaryIDs = ["ironguard-soldier", "ironguard-soldier", "thornwood-archer"] + definition.environmentIDs = ["collapsing-bridge"] + definition.playerConfigs = [ + PlayerConfig( + name: "Aldric", maxHP: 6, maxStress: 6, + evasion: 12, thresholdMajor: 8, thresholdSevere: 15, armorSlots: 3 + ) + ] + definition.gmNotes = "Start with archers hidden." + + let data = try JSONEncoder().encode(definition) + let decoded = try JSONDecoder().decode(EncounterDefinition.self, from: data) + + #expect(decoded.name == "Bandit Ambush") + #expect(decoded.adversaryIDs.count == 3) + #expect(decoded.environmentIDs == ["collapsing-bridge"]) + #expect(decoded.playerConfigs.count == 1) + #expect(decoded.playerConfigs[0].name == "Aldric") + #expect(decoded.gmNotes == "Start with archers hidden.") + } + + @Test func definitionHasTimestamps() { + let before = Date.now + let definition = EncounterDefinition(name: "Test") + let after = Date.now + #expect(definition.createdAt >= before) + #expect(definition.createdAt <= after) + #expect(definition.modifiedAt >= before) + } + + @Test func modifiedAtOnlyChangesAfterSave() { + var def = EncounterDefinition(name: "Test") + let before = def.modifiedAt + def.name = "Changed" + def.adversaryIDs = ["ironguard-soldier"] + def.gmNotes = "Remember the trap." + // Direct property mutations must NOT update modifiedAt — only store.save() stamps it. + #expect(def.modifiedAt == before) + } + + @Test func decodingDoesNotResetModifiedAt() throws { + let definition = EncounterDefinition( + name: "Test", + modifiedAt: Date(timeIntervalSince1970: 1_000_000) + ) + let data = try JSONEncoder().encode(definition) + let decoded = try JSONDecoder().decode(EncounterDefinition.self, from: data) + #expect(decoded.modifiedAt == definition.modifiedAt) + } + + @Test func playerConfigCodableRoundTrip() throws { + let config = PlayerConfig( + name: "Sera", maxHP: 8, maxStress: 6, + evasion: 14, thresholdMajor: 10, thresholdSevere: 18, armorSlots: 4 + ) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(PlayerConfig.self, from: data) + + #expect(decoded.name == "Sera") + #expect(decoded.maxHP == 8) + #expect(decoded.evasion == 14) + #expect(decoded.armorSlots == 4) + } +} + +// MARK: - DifficultyBudget + +struct DifficultyBudgetTests { + + // MARK: Battle Point Costs + + @Test func minionCostsOnePoint() { + #expect(DifficultyBudget.cost(for: .minion) == 1) + } + + @Test func socialAndSupportCostOnePoint() { + #expect(DifficultyBudget.cost(for: .social) == 1) + #expect(DifficultyBudget.cost(for: .support) == 1) + } + + @Test func standardTierCostsTwoPoints() { + for type in [AdversaryType.horde, .ranged, .skulk, .standard] { + #expect( + DifficultyBudget.cost(for: type) == 2, + "Expected \(type) to cost 2, got \(DifficultyBudget.cost(for: type))" + ) + } + } + + @Test func leaderCostsThreePoints() { + #expect(DifficultyBudget.cost(for: .leader) == 3) + } + + @Test func bruiserCostsFourPoints() { + #expect(DifficultyBudget.cost(for: .bruiser) == 4) + } + + @Test func soloCostsFivePoints() { + #expect(DifficultyBudget.cost(for: .solo) == 5) + } + + // MARK: Base Budget + + @Test func baseBudgetForFourPCs() { + #expect(DifficultyBudget.baseBudget(playerCount: 4) == 14) + } + + @Test func baseBudgetForThreePCs() { + #expect(DifficultyBudget.baseBudget(playerCount: 3) == 11) + } + + @Test func baseBudgetForOnePCMinimum() { + #expect(DifficultyBudget.baseBudget(playerCount: 1) == 5) + } + + // MARK: Total Cost + + @Test func totalCostForAdversaryList() { + let types: [AdversaryType] = [.minion, .minion, .bruiser, .leader] + #expect(DifficultyBudget.totalCost(for: types) == 9) + } + + // MARK: Rating + + @Test func ratingWithinBudgetIsBalanced() { + let rating = DifficultyBudget.rating( + adversaryTypes: [.standard, .standard, .minion], + playerCount: 4 + ) + #expect(rating.totalCost == 5) + #expect(rating.budget == 14) + #expect(rating.remaining == 9) + } + + @Test func ratingOverBudgetShowsNegativeRemaining() { + let rating = DifficultyBudget.rating( + adversaryTypes: [.solo, .solo, .bruiser], + playerCount: 3 + ) + #expect(rating.totalCost == 14) + #expect(rating.budget == 11) + #expect(rating.remaining == -3) + } + + @Test func ratingWithBudgetAdjustment() { + let rating = DifficultyBudget.rating( + adversaryTypes: [.standard], + playerCount: 4, + budgetAdjustment: -2 + ) + #expect(rating.budget == 12) + #expect(rating.totalCost == 2) + #expect(rating.remaining == 10) + } + + // MARK: Adjustment Suggestions + + @Test func adjustmentForMultipleSolos() { + let adjustments = DifficultyBudget.suggestedAdjustments( + adversaryTypes: [.solo, .solo] + ) + #expect(adjustments.contains(.multipleSolos)) + } + + @Test func noMultipleSolosForSingleSolo() { + let adjustments = DifficultyBudget.suggestedAdjustments( + adversaryTypes: [.solo] + ) + #expect(!adjustments.contains(.multipleSolos)) + } + + @Test func adjustmentForNoBigThreats() { + let adjustments = DifficultyBudget.suggestedAdjustments( + adversaryTypes: [.standard, .minion, .ranged] + ) + #expect(adjustments.contains(.noBigThreats)) + } + + @Test func noBigThreatsNotSuggestedWhenBruiserPresent() { + let adjustments = DifficultyBudget.suggestedAdjustments( + adversaryTypes: [.standard, .bruiser] + ) + #expect(!adjustments.contains(.noBigThreats)) + } + + @Test func emptyRosterHasNoSuggestions() { + let adjustments = DifficultyBudget.suggestedAdjustments(adversaryTypes: []) + #expect(adjustments.isEmpty) + } + + @Test func adjustmentPointValues() { + #expect(DifficultyBudget.Adjustment.easierFight.pointValue == -1) + #expect(DifficultyBudget.Adjustment.multipleSolos.pointValue == -2) + #expect(DifficultyBudget.Adjustment.boostedDamage.pointValue == -2) + #expect(DifficultyBudget.Adjustment.lowerTierAdversary.pointValue == 1) + #expect(DifficultyBudget.Adjustment.noBigThreats.pointValue == 1) + #expect(DifficultyBudget.Adjustment.harderFight.pointValue == 2) + } +} + +// MARK: - DaggerheartEnvironment + +struct EnvironmentModelTests { + + @Test func decodesFromJSON() throws { + let json = """ + { + "id": "arcane-storm", + "name": "Arcane Storm", + "source": "SRD", + "description": "A tempest of wild magic.", + "feature": [ + { "name": "Wild Discharge", "text": "Deals damage at random.", "feat_type": "passive" } + ] + } + """.data(using: .utf8)! + + let env = try JSONDecoder().decode(DaggerheartEnvironment.self, from: json) + #expect(env.id == "arcane-storm") + #expect(env.features.count == 1) + #expect(env.features[0].featType == FeatureType.passive) + } +} diff --git a/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/SRDDecodeTests.swift b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/SRDDecodeTests.swift new file mode 100644 index 0000000..7268c63 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/Tests/DaggerheartModelsTests/SRDDecodeTests.swift @@ -0,0 +1,83 @@ +// +// SRDDecodeTests.swift +// DaggerheartModelsTests +// +// Loads the bundled SRD JSON from the test target's Fixtures directory +// and verifies that every entry decodes without error. +// +// The JSON files are declared as test resources in Package.swift so that +// Bundle.module resolves them correctly on all platforms including Linux. +// + +import Foundation +import Testing + +@testable import DaggerheartModels + +struct SRDDecodeTests { + + private static func url(forResource name: String) throws -> URL { + try #require( + Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures")) + } + + @Test func allSRDAdversariesDecodeWithoutError() throws { + let url = try Self.url(forResource: "adversaries") + let data = try Data(contentsOf: url) + let adversaries = try JSONDecoder().decode([Adversary].self, from: data) + #expect(adversaries.isEmpty == false) + for adversary in adversaries { + #expect(!adversary.name.isEmpty, "Adversary id=\(adversary.id) has empty name") + #expect( + (1...4).contains(adversary.tier), + "Adversary \(adversary.name) has unexpected tier \(adversary.tier)") + } + } + + @Test func allSRDEnvironmentsDecodeWithoutError() throws { + let url = try Self.url(forResource: "environments") + let data = try Data(contentsOf: url) + let environments = try JSONDecoder().decode([DaggerheartEnvironment].self, from: data) + #expect(environments.isEmpty == false) + for environment in environments { + #expect(!environment.name.isEmpty, "Environment id=\(environment.id) has empty name") + } + } + + @Test func srdAdversaryCountMatchesExpected() throws { + let url = try Self.url(forResource: "adversaries") + let data = try Data(contentsOf: url) + let adversaries = try JSONDecoder().decode([Adversary].self, from: data) + #expect(adversaries.count == 129) + } + + @Test func srdEnvironmentCountMatchesExpected() throws { + let url = try Self.url(forResource: "environments") + let data = try Data(contentsOf: url) + let environments = try JSONDecoder().decode([DaggerheartEnvironment].self, from: data) + #expect(environments.count == 19) + } + + @Test func srdAdversaryIDsAreUnique() throws { + let url = try Self.url(forResource: "adversaries") + let data = try Data(contentsOf: url) + let adversaries = try JSONDecoder().decode([Adversary].self, from: data) + let ids = adversaries.map(\.id) + let unique = Set(ids) + #expect( + unique.count == ids.count, + "Duplicate adversary IDs found: \(ids.count - unique.count) duplicates") + } + + @Test func srdAdversariesHaveNonEmptyFeatureText() throws { + let url = try Self.url(forResource: "adversaries") + let data = try Data(contentsOf: url) + let adversaries = try JSONDecoder().decode([Adversary].self, from: data) + for adversary in adversaries { + for feature in adversary.features { + #expect(!feature.name.isEmpty, "\(adversary.name) has a feature with empty name") + #expect(!feature.text.isEmpty, "\(adversary.name) feature '\(feature.name)' has empty text") + } + } + } +} diff --git a/.claude/worktrees/agent-a84e8640/schemas/dhpack.schema.json b/.claude/worktrees/agent-a84e8640/schemas/dhpack.schema.json new file mode 100644 index 0000000..3a82eb5 --- /dev/null +++ b/.claude/worktrees/agent-a84e8640/schemas/dhpack.schema.json @@ -0,0 +1,226 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cdn.jsdelivr.net/gh/gwillish/DaggerheartModels@main/schemas/dhpack.schema.json", + "title": "DHPack", + "description": "A .dhpack content pack for the Encounter app. Accepts either a pack wrapper object (recommended) or a bare adversary array (seansbox/daggerheart-srd export format).", + "oneOf": [ + { "$ref": "#/$defs/PackWrapper" }, + { + "type": "array", + "items": { "$ref": "#/$defs/Adversary" }, + "description": "Bare adversary array — seansbox/daggerheart-srd direct export format." + } + ], + "$defs": { + "PackWrapper": { + "type": "object", + "description": "Recommended top-level structure. Supports adversaries, environments, or both.", + "properties": { + "$schema": { + "type": "string", + "description": "Optional $schema reference for editor validation." + }, + "adversaries": { + "type": "array", + "items": { "$ref": "#/$defs/Adversary" }, + "description": "Array of adversary objects to import." + }, + "environments": { + "type": "array", + "items": { "$ref": "#/$defs/Environment" }, + "description": "Array of environment objects to import." + } + }, + "additionalProperties": false + }, + "Adversary": { + "type": "object", + "description": "A Daggerheart adversary definition.", + "required": [ + "name", + "tier", + "type", + "description", + "difficulty", + "hp", + "stress", + "atk", + "attack", + "range", + "damage" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$", + "description": "URL-safe slug, e.g. 'iron-golem'. Auto-derived from name if absent." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Display name." + }, + "source": { + "type": "string", + "description": "Pack name, author, or 'homebrew'. Stored lowercase." + }, + "tier": { + "$ref": "#/$defs/IntOrNumericString", + "description": "PC level tier 1–4 this adversary is designed for." + }, + "type": { + "type": "string", + "enum": [ + "Bruiser", + "Horde", + "Leader", + "Minion", + "Ranged", + "Skulk", + "Social", + "Solo", + "Standard", + "Support" + ], + "description": "Adversary role. Horde variant strings like 'Horde (3/HP)' are normalized to 'Horde' on import." + }, + "description": { + "type": "string", + "minLength": 1, + "description": "One-line appearance or demeanor." + }, + "motives_and_tactics": { + "type": "string", + "description": "GM-facing guidance on how to run this adversary." + }, + "difficulty": { + "$ref": "#/$defs/IntOrNumericString", + "description": "DC for all player rolls against this adversary." + }, + "thresholds": { + "type": "string", + "pattern": "^(None|\\d+/\\d+|\\d+/None|None/\\d+)$", + "description": "Combined major/severe threshold string, e.g. '8/15'. Use 'None' for minions with no damage thresholds. Provide either this field or threshold_major + threshold_severe." + }, + "threshold_major": { + "type": "integer", + "minimum": 0, + "description": "Pre-split alternative to thresholds. Damage required for a Major hit." + }, + "threshold_severe": { + "type": "integer", + "minimum": 0, + "description": "Pre-split alternative to thresholds. Damage required for a Severe hit." + }, + "hp": { + "$ref": "#/$defs/IntOrNumericString", + "description": "Hit points." + }, + "stress": { + "$ref": "#/$defs/IntOrNumericString", + "description": "Stress capacity." + }, + "atk": { + "type": "string", + "pattern": "^[+-]\\d+$", + "description": "Attack modifier string, e.g. '+3' or '-1'." + }, + "attack": { + "type": "string", + "minLength": 1, + "description": "Name of the standard attack or weapon." + }, + "range": { + "type": "string", + "enum": ["Melee", "Very Close", "Close", "Far", "Very Far"], + "description": "Attack range." + }, + "damage": { + "type": "string", + "pattern": "^\\d+d\\d+(\\+\\d+)?\\s+(phy|mag)$", + "description": "Damage expression, e.g. '1d12+2 phy' or '2d6 mag'." + }, + "experience": { + "type": "string", + "description": "Optional experience tag, e.g. 'Tremor Sense +2'." + }, + "feature": { + "type": "array", + "items": { "$ref": "#/$defs/Feature" }, + "description": "Actions, reactions, and passives." + } + }, + "oneOf": [ + { + "required": ["thresholds"], + "description": "Combined threshold string variant." + }, + { + "required": ["threshold_major", "threshold_severe"], + "description": "Pre-split threshold keys variant." + } + ], + "additionalProperties": false + }, + "Environment": { + "type": "object", + "description": "A Daggerheart environment — a scene element with features but no HP or Stress.", + "required": ["name", "description"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$", + "description": "URL-safe slug. Auto-derived from name if absent." + }, + "name": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string", + "description": "Pack name or author. Stored lowercase." + }, + "description": { + "type": "string", + "minLength": 1 + }, + "feature": { + "type": "array", + "items": { "$ref": "#/$defs/Feature" }, + "description": "Passives, reactions, and actions this environment contributes to the scene." + } + }, + "additionalProperties": false + }, + "Feature": { + "type": "object", + "description": "A named action, reaction, or passive on an adversary or environment.", + "required": ["name", "text"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Unique name within this adversary or environment. May include a ' - Type' suffix (e.g. 'Bite - Action') to allow feat_type to be inferred." + }, + "text": { + "type": "string", + "minLength": 1, + "description": "Rules text." + }, + "feat_type": { + "type": "string", + "enum": ["action", "reaction", "passive"], + "description": "Feature category. If absent, inferred from a ' - Action' or ' - Reaction' suffix in name; defaults to 'passive'." + } + }, + "additionalProperties": false + }, + "IntOrNumericString": { + "description": "Accepts either a JSON integer or a string containing an integer, for compatibility with SRD tooling that serializes all numeric fields as strings.", + "oneOf": [ + { "type": "integer" }, + { "type": "string", "pattern": "^\\d+$" } + ] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 675a1bc..6a655df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,7 +135,7 @@ Use this decision tree to choose the right target: or be referenced by `validate-dhpack`. Test in `DHModelsTests`. - **`DHKit`** — everything else on Apple platforms: `@Observable` stores (`Compendium`, `EncounterStore`, `SessionRegistry`), live session types - (`EncounterSession`, `AdversarySlot`, `PlayerSlot`, `EnvironmentSlot`, + (`EncounterSession`, `AdversaryState`, `PlayerState`, `EnvironmentState`, `EncounterParticipant`/`CombatParticipant`), and any type that requires `@MainActor`, iCloud/FileManager persistence, or other Apple-only infrastructure. Test in `DHKitTests`. diff --git a/Sources/DHKit/AdversarySlot.swift b/Sources/DHKit/AdversaryState.swift similarity index 86% rename from Sources/DHKit/AdversarySlot.swift rename to Sources/DHKit/AdversaryState.swift index 848f1e8..a1362d7 100644 --- a/Sources/DHKit/AdversarySlot.swift +++ b/Sources/DHKit/AdversaryState.swift @@ -1,15 +1,15 @@ // -// AdversarySlot.swift +// AdversaryState.swift // DHKit // // A single adversary participant in a live encounter. // import DHModels -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif /// A single adversary participant in a live encounter. @@ -18,13 +18,13 @@ import Foundation /// current HP, current Stress, defeat status, and an optional individual name /// (useful when running multiple copies of the same adversary). /// -/// `maxHP` and `maxStress` are snapshotted from the catalog at slot-creation +/// `maxHP` and `maxStress` are snapshotted from the catalog at creation /// time so that HP/stress clamping works correctly even if the source adversary /// is later edited or removed from the ``Compendium`` (homebrew orphan safety). /// /// All properties are immutable. Mutations are performed by ``EncounterSession``, -/// which replaces slots wholesale (copy-with-update pattern). -nonisolated public struct AdversarySlot: CombatParticipant, Sendable, Equatable, Hashable { +/// which replaces values wholesale (copy-with-update pattern). +nonisolated public struct AdversaryState: CombatParticipant, Sendable, Equatable, Hashable { public let id: UUID /// The slug that identifies this adversary in the ``Compendium``. public let adversaryID: String @@ -66,7 +66,7 @@ nonisolated public struct AdversarySlot: CombatParticipant, Sendable, Equatable, self.conditions = conditions } - /// Convenience initializer: creates a slot pre-populated from a catalog entry. + /// Convenience initializer: creates state pre-populated from a catalog entry. public init(from adversary: Adversary, customName: String? = nil) { self.init( adversaryID: adversary.id, @@ -76,7 +76,7 @@ nonisolated public struct AdversarySlot: CombatParticipant, Sendable, Equatable, ) } - /// Returns a copy of this slot with the specified mutable fields replaced. + /// Returns a copy of this value with the specified mutable fields replaced. /// /// Omit any parameter to preserve the existing value. This is the preferred /// way to produce updated copies; it avoids repeating every unchanged field @@ -86,8 +86,8 @@ nonisolated public struct AdversarySlot: CombatParticipant, Sendable, Equatable, currentStress: Int? = nil, isDefeated: Bool? = nil, conditions: Set? = nil - ) -> AdversarySlot { - AdversarySlot( + ) -> AdversaryState { + AdversaryState( id: id, adversaryID: adversaryID, customName: customName, maxHP: maxHP, maxStress: maxStress, currentHP: currentHP ?? self.currentHP, diff --git a/Sources/DHKit/Compendium.swift b/Sources/DHKit/Compendium.swift index 7575e79..f27bcea 100644 --- a/Sources/DHKit/Compendium.swift +++ b/Sources/DHKit/Compendium.swift @@ -14,14 +14,14 @@ // import DHModels -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import Logging import Observation +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + // MARK: - CompendiumError /// Errors that can occur while loading compendium data. @@ -34,7 +34,7 @@ nonisolated public enum CompendiumError: Error, LocalizedError { case .fileNotFound(let resourceName): return "Compendium resource '\(resourceName)' not found in app bundle." case .decodingFailed(let resourceName, let underlying): - return "Failed to decode '\(resourceName)': \(underlying.localizedDescription)" + return "Failed to decode '\(resourceName)': \(underlying)" } } } @@ -220,7 +220,7 @@ public final class Compendium { ) } catch let error as CompendiumError { loadError = error - logger.error("Compendium load failed: \(error.localizedDescription)") + logger.error("Compendium load failed: \(error)") throw error } catch { let wrapped = CompendiumError.decodingFailed(resourceName: "unknown", underlying: error) diff --git a/Sources/DHKit/DHKit.docc/DaggerheartKit.md b/Sources/DHKit/DHKit.docc/DaggerheartKit.md index f37a104..29cbe92 100644 --- a/Sources/DHKit/DHKit.docc/DaggerheartKit.md +++ b/Sources/DHKit/DHKit.docc/DaggerheartKit.md @@ -15,6 +15,7 @@ All types require Apple platforms (iOS 17+, macOS 14+) and are isolated to ### Compendium - ``Compendium`` +- ``CompendiumError`` ### Encounter Persistence @@ -24,6 +25,12 @@ All types require Apple platforms (iOS 17+, macOS 14+) and are isolated to ### Live Session - ``EncounterSession`` -- ``AdversarySlot`` -- ``EnvironmentSlot`` +- ``AdversaryState`` +- ``PlayerState`` +- ``EnvironmentState`` - ``SessionRegistry`` + +### Session Participant Protocols + +- ``EncounterParticipant`` +- ``CombatParticipant`` diff --git a/Sources/DHKit/DHKit.docc/Resources/encounterstore.svg b/Sources/DHKit/DHKit.docc/Resources/encounterstore.svg index d9cf9d4..07abc36 100644 --- a/Sources/DHKit/DHKit.docc/Resources/encounterstore.svg +++ b/Sources/DHKit/DHKit.docc/Resources/encounterstore.svg @@ -1 +1 @@ -

throws

«Observable, MainActor»

EncounterStore

+[EncounterDefinition] definitions

+URL directory

+Bool isLoading

+Error? loadError

+localDirectory URL

+init(directory: URL)

+defaultDirectory() : URL$ async

+relocate(to:)

+load() : async

+create(name:) : async throws

+save(_:) : async throws

+delete(id:) : async throws

+duplicate(id:) : async throws

«enumeration»

EncounterStoreError

notFound(UUID)

saveFailed(UUID, String)

deleteFailed(UUID, String)

\ No newline at end of file +

throws

«Observable, MainActor»

EncounterStore

+[EncounterDefinition] definitions

+URL directory

+Bool isLoading

+Error? loadError

+localDirectory URL

+init(directory: URL)

+defaultDirectory() : URL$ async

+relocate(to:)

+load() : async

+create(name:) : async throws

+save(_:) : async throws

+delete(id:) : async throws

+duplicate(id:) : async throws

«enumeration»

EncounterStoreError

notFound(UUID)

saveFailed(UUID, String)

deleteFailed(UUID, String)

\ No newline at end of file diff --git a/Sources/DHKit/DHKit.docc/Resources/participants.svg b/Sources/DHKit/DHKit.docc/Resources/participants.svg index 2988747..8b3eccf 100644 --- a/Sources/DHKit/DHKit.docc/Resources/participants.svg +++ b/Sources/DHKit/DHKit.docc/Resources/participants.svg @@ -1 +1 @@ -

«protocol»

EncounterParticipant

+UUID id

«protocol»

CombatParticipant

+Int currentHP

+Int maxHP

+Int currentStress

+Int maxStress

+Set<Condition> conditions

AdversarySlot

+UUID id

+String adversaryID

+String? customName

+Int maxHP

+Int maxStress

+Int currentHP

+Int currentStress

+Bool isDefeated

+Set<Condition> conditions

+init(from: Adversary, customName:)

+applying(currentHP:currentStress:isDefeated:conditions:) : AdversarySlot

PlayerSlot

+UUID id

+String name

+Int maxHP

+Int currentHP

+Int maxStress

+Int currentStress

+Int evasion

+Int thresholdMajor

+Int thresholdSevere

+Int armorSlots

+Int currentArmorSlots

+Set<Condition> conditions

+applying(currentHP:currentStress:currentArmorSlots:conditions:) : PlayerSlot

EnvironmentSlot

+UUID id

+String environmentID

+Bool isActive

+applying(isActive:) : EnvironmentSlot

\ No newline at end of file +

«protocol»

EncounterParticipant

+UUID id

«protocol»

CombatParticipant

+Int currentHP

+Int maxHP

+Int currentStress

+Int maxStress

+Set<Condition> conditions

AdversaryState

+UUID id

+String adversaryID

+String? customName

+Int maxHP

+Int maxStress

+Int currentHP

+Int currentStress

+Bool isDefeated

+Set<Condition> conditions

+init(from: Adversary, customName:)

+applying(currentHP:currentStress:isDefeated:conditions:) : AdversaryState

PlayerState

+UUID id

+String name

+Int maxHP

+Int currentHP

+Int maxStress

+Int currentStress

+Int evasion

+Int thresholdMajor

+Int thresholdSevere

+Int armorSlots

+Int currentArmorSlots

+Set<Condition> conditions

+applying(currentHP:currentStress:currentArmorSlots:conditions:) : PlayerState

EnvironmentState

+UUID id

+String environmentID

+Bool isActive

+applying(isActive:) : EnvironmentState

\ No newline at end of file diff --git a/Sources/DHKit/DHKit.docc/Resources/stores.svg b/Sources/DHKit/DHKit.docc/Resources/stores.svg index cf83021..ae2c3a3 100644 --- a/Sources/DHKit/DHKit.docc/Resources/stores.svg +++ b/Sources/DHKit/DHKit.docc/Resources/stores.svg @@ -1 +1 @@ -

adversarySlots

playerSlots

environmentSlots

throws

sessions

resolves catalog via make

resolves catalog

creates and owns

1

1

1

1

0..*

0..*

0..*

0..*

«Observable, MainActor»

EncounterSession

+UUID id

+String name

+[AdversarySlot] adversarySlots

+[PlayerSlot] playerSlots

+[EnvironmentSlot] environmentSlots

+Int fearPool

+Int hopePool

+UUID? spotlightedSlotID

+Int spotlightCount

+String gmNotes

+[AdversarySlot] activeAdversaries

+Bool isOver

+add(adversary: Adversary, customName:)

+add(environment: DaggerheartEnvironment)

+add(player: PlayerSlot)

+removeAdversary(withID:)

+removePlayer(withID:)

+spotlight(id:)

+yieldSpotlight()

+applyDamage(_:to:)

+applyHealing(_:to:)

+applyStress(_:to:)

+reduceStress(_:from:)

+applyCondition(_:to:)

+removeCondition(_:from:)

+markArmorSlot(for:)

+restoreArmorSlot(for:)

+incrementFear(by:)

+spendFear(by:)

+incrementHope(by:)

+spendHope(by:)

+make(from: EncounterDefinition, using: Compendium) : EncounterSession

AdversarySlot

+UUID id

PlayerSlot

+UUID id

EnvironmentSlot

+UUID id

«Observable, MainActor»

Compendium

+[String: Adversary] adversariesByID

+[String: DaggerheartEnvironment] environmentsByID

+[Adversary] adversaries

+[DaggerheartEnvironment] environments

+[Adversary] homebrewAdversaries

+[DaggerheartEnvironment] homebrewEnvironments

+Bool isLoading

+CompendiumError? loadError

+init(bundle: Bundle?)

+load() : async throws

+adversary(id:) : Adversary?

+environment(id:) : DaggerheartEnvironment?

+adversaries(ofTier:) : [Adversary]

+adversaries(ofRole:) : [Adversary]

+adversaries(matching:) : [Adversary]

+addAdversary(_:)

+removeHomebrewAdversary(id:)

+addEnvironment(_:)

+removeHomebrewEnvironment(id:)

+replaceSRDContent(adversaries:environments:)

+replaceSourceContent(sourceID:adversaries:environments:)

+removeSourceContent(sourceID:)

«enumeration»

CompendiumError

fileNotFound(resourceName:)

decodingFailed(resourceName:underlying:)

«Observable, MainActor»

SessionRegistry

+[UUID: EncounterSession] sessions

+init()

+session(for:definition:compendium:) : EncounterSession

+clearSession(for:)

+resetSession(for:definition:compendium:) : EncounterSession

\ No newline at end of file +

adversarySlots

playerSlots

environmentSlots

throws

sessions

resolves catalog via make

resolves catalog

creates and owns

1

1

1

1

0..*

0..*

0..*

0..*

«Observable, MainActor»

EncounterSession

+UUID id

+String name

+[AdversaryState] adversarySlots

+[PlayerState] playerSlots

+[EnvironmentState] environmentSlots

+Int fearPool

+Int hopePool

+UUID? spotlightedSlotID

+Int spotlightCount

+String gmNotes

+[AdversaryState] activeAdversaries

+Bool isOver

+add(adversary: Adversary, customName:)

+add(environment: DaggerheartEnvironment)

+add(player: PlayerState)

+removeAdversary(withID:)

+removePlayer(withID:)

+spotlight(id:)

+yieldSpotlight()

+applyDamage(_:to:)

+applyHealing(_:to:)

+applyStress(_:to:)

+reduceStress(_:from:)

+applyCondition(_:to:)

+removeCondition(_:from:)

+markArmorSlot(for:)

+restoreArmorSlot(for:)

+incrementFear(by:)

+spendFear(by:)

+incrementHope(by:)

+spendHope(by:)

+make(from: EncounterDefinition, using: Compendium) : EncounterSession

AdversaryState

+UUID id

PlayerState

+UUID id

EnvironmentState

+UUID id

«Observable, MainActor»

Compendium

+[String: Adversary] adversariesByID

+[String: DaggerheartEnvironment] environmentsByID

+[Adversary] adversaries

+[DaggerheartEnvironment] environments

+[Adversary] homebrewAdversaries

+[DaggerheartEnvironment] homebrewEnvironments

+Bool isLoading

+CompendiumError? loadError

+init(bundle: Bundle?)

+load() : async throws

+adversary(id:) : Adversary?

+environment(id:) : DaggerheartEnvironment?

+adversaries(ofTier:) : [Adversary]

+adversaries(ofRole:) : [Adversary]

+adversaries(matching:) : [Adversary]

+addAdversary(_:)

+removeHomebrewAdversary(id:)

+addEnvironment(_:)

+removeHomebrewEnvironment(id:)

+replaceSRDContent(adversaries:environments:)

+replaceSourceContent(sourceID:adversaries:environments:)

+removeSourceContent(sourceID:)

«enumeration»

CompendiumError

fileNotFound(resourceName:)

decodingFailed(resourceName:underlying:)

«Observable, MainActor»

SessionRegistry

+[UUID: EncounterSession] sessions

+init()

+session(for:definition:compendium:) : EncounterSession

+clearSession(for:)

+resetSession(for:definition:compendium:) : EncounterSession

\ No newline at end of file diff --git a/Sources/DHKit/EncounterParticipant.swift b/Sources/DHKit/EncounterParticipant.swift index 91da7c2..a194978 100644 --- a/Sources/DHKit/EncounterParticipant.swift +++ b/Sources/DHKit/EncounterParticipant.swift @@ -7,10 +7,10 @@ // import DHModels -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif /// A participant in a Daggerheart encounter that can hold the spotlight. @@ -21,7 +21,7 @@ nonisolated public protocol EncounterParticipant: Identifiable where ID == UUID /// An encounter participant that tracks HP, Stress, and Conditions. /// -/// Conformed to by ``AdversarySlot`` and ``PlayerSlot``. Used as a read/display +/// Conformed to by ``AdversaryState`` and ``PlayerState``. Used as a read/display /// contract; all mutations are performed by ``EncounterSession`` via UUID. nonisolated public protocol CombatParticipant: EncounterParticipant { var currentHP: Int { get } diff --git a/Sources/DHKit/EncounterSession.swift b/Sources/DHKit/EncounterSession.swift index bfb850b..3e81cb3 100644 --- a/Sources/DHKit/EncounterSession.swift +++ b/Sources/DHKit/EncounterSession.swift @@ -8,7 +8,7 @@ // // Design notes: // - EncounterSession is @Observable so SwiftUI views bind to it directly. -// - AdversarySlot, PlayerSlot, and EnvironmentSlot are immutable structs stored +// - AdversaryState, PlayerState, and EnvironmentState are immutable structs stored // in private backing arrays; mutations replace the affected struct wholesale // (copy-with-update). Public computed properties expose read-only snapshots. // - Fear and Hope are tracked on the session; individual adversary stress @@ -17,14 +17,14 @@ // import DHModels -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import Logging import Observation +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + // MARK: - EncounterSession /// The live state of a Daggerheart encounter being run at the table. @@ -61,16 +61,16 @@ public final class EncounterSession: Identifiable, Hashable { public var name: String // MARK: Participants (private backing stores) - private var _adversarySlots: [AdversarySlot] - private var _playerSlots: [PlayerSlot] - private var _environmentSlots: [EnvironmentSlot] + private var _adversarySlots: [AdversaryState] + private var _playerSlots: [PlayerState] + private var _environmentSlots: [EnvironmentState] /// Read-only snapshot of all adversary slots. - public var adversarySlots: [AdversarySlot] { _adversarySlots } + public var adversarySlots: [AdversaryState] { _adversarySlots } /// Read-only snapshot of all player slots. - public var playerSlots: [PlayerSlot] { _playerSlots } + public var playerSlots: [PlayerState] { _playerSlots } /// Read-only snapshot of all environment slots. - public var environmentSlots: [EnvironmentSlot] { _environmentSlots } + public var environmentSlots: [EnvironmentState] { _environmentSlots } // MARK: Fear & Hope /// The GM's Fear pool. Increases when players roll with Fear, @@ -97,9 +97,9 @@ public final class EncounterSession: Identifiable, Hashable { public init( id: UUID = UUID(), name: String, - adversarySlots: [AdversarySlot] = [], - playerSlots: [PlayerSlot] = [], - environmentSlots: [EnvironmentSlot] = [], + adversarySlots: [AdversaryState] = [], + playerSlots: [PlayerState] = [], + environmentSlots: [EnvironmentState] = [], fearPool: Int = 0, hopePool: Int = 0, spotlightCount: Int = 0, @@ -121,12 +121,12 @@ public final class EncounterSession: Identifiable, Hashable { /// Add a new adversary slot populated from a catalog entry. public func add(adversary: Adversary, customName: String? = nil) { - _adversarySlots.append(AdversarySlot(from: adversary, customName: customName)) + _adversarySlots.append(AdversaryState(from: adversary, customName: customName)) } /// Add an environment slot. public func add(environment: DaggerheartEnvironment) { - _environmentSlots.append(EnvironmentSlot(environmentID: environment.id)) + _environmentSlots.append(EnvironmentState(environmentID: environment.id)) } /// Remove an adversary slot by ID. @@ -138,7 +138,7 @@ public final class EncounterSession: Identifiable, Hashable { // MARK: - Player Management /// Add a player slot to the encounter. - public func add(player: PlayerSlot) { + public func add(player: PlayerState) { _playerSlots.append(player) } @@ -171,7 +171,7 @@ public final class EncounterSession: Identifiable, Hashable { // MARK: - HP & Stress Mutations /// Apply damage to a combat participant by ID, clamping HP to 0. - /// Adversary slots are marked ``AdversarySlot/isDefeated`` when HP reaches 0. + /// Adversary slots are marked ``AdversaryState/isDefeated`` when HP reaches 0. public func applyDamage(_ amount: Int, to id: UUID) { if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { let s = _adversarySlots[i] @@ -193,7 +193,7 @@ public final class EncounterSession: Identifiable, Hashable { } /// Heal a combat participant by ID, clamping HP to the slot's maximum. - /// Clears ``AdversarySlot/isDefeated`` if the adversary's HP rises above 0. + /// Clears ``AdversaryState/isDefeated`` if the adversary's HP rises above 0. public func applyHealing(_ amount: Int, to id: UUID) { if let i = _adversarySlots.firstIndex(where: { $0.id == id }) { let s = _adversarySlots[i] @@ -322,7 +322,7 @@ public final class EncounterSession: Identifiable, Hashable { // MARK: - Computed Helpers /// All adversary slots still in the fight. - public var activeAdversaries: [AdversarySlot] { + public var activeAdversaries: [AdversaryState] { _adversarySlots.filter { !$0.isDefeated } } @@ -347,18 +347,18 @@ public final class EncounterSession: Identifiable, Hashable { from definition: EncounterDefinition, using compendium: Compendium ) -> EncounterSession { - let adversarySlots: [AdversarySlot] = definition.adversaryIDs.compactMap { id in + let adversarySlots: [AdversaryState] = definition.adversaryIDs.compactMap { id in guard let adversary = compendium.adversary(id: id) else { return nil } - return AdversarySlot(from: adversary) + return AdversaryState(from: adversary) } - let environmentSlots: [EnvironmentSlot] = definition.environmentIDs.compactMap { id in + let environmentSlots: [EnvironmentState] = definition.environmentIDs.compactMap { id in guard compendium.environment(id: id) != nil else { return nil } - return EnvironmentSlot(environmentID: id) + return EnvironmentState(environmentID: id) } - let playerSlots: [PlayerSlot] = definition.playerConfigs.map { config in - PlayerSlot( + let playerSlots: [PlayerState] = definition.playerConfigs.map { config in + PlayerState( name: config.name, maxHP: config.maxHP, maxStress: config.maxStress, diff --git a/Sources/DHKit/EncounterStore.swift b/Sources/DHKit/EncounterStore.swift index 46ea287..1b85d8f 100644 --- a/Sources/DHKit/EncounterStore.swift +++ b/Sources/DHKit/EncounterStore.swift @@ -16,13 +16,13 @@ // import DHModels -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import Observation +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + // MARK: - EncounterStoreError /// Errors thrown by ``EncounterStore`` operations. @@ -274,7 +274,7 @@ public final class EncounterStore { do { try await Self.deleteEncounter(at: url) } catch { - throw EncounterStoreError.deleteFailed(id, error.localizedDescription) + throw EncounterStoreError.deleteFailed(id, "\(error)") } definitions.removeAll { $0.id == id } } @@ -326,7 +326,7 @@ public final class EncounterStore { do { try await Self.writeEncounter(definition, to: url) } catch { - throw EncounterStoreError.saveFailed(definition.id, error.localizedDescription) + throw EncounterStoreError.saveFailed(definition.id, "\(error)") } } diff --git a/Sources/DHKit/EnvironmentSlot.swift b/Sources/DHKit/EnvironmentState.swift similarity index 71% rename from Sources/DHKit/EnvironmentSlot.swift rename to Sources/DHKit/EnvironmentState.swift index 15748f5..d0ce086 100644 --- a/Sources/DHKit/EnvironmentSlot.swift +++ b/Sources/DHKit/EnvironmentState.swift @@ -1,14 +1,14 @@ // -// EnvironmentSlot.swift +// EnvironmentState.swift // DHKit // // An environment element active in the current encounter scene. // -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif /// An environment element active in the current encounter scene. @@ -17,8 +17,8 @@ import Foundation /// their features and activation state. /// /// All properties are immutable. Mutations are performed by ``EncounterSession``, -/// which replaces slots wholesale (copy-with-update pattern). -nonisolated public struct EnvironmentSlot: EncounterParticipant, Sendable, Equatable, Hashable { +/// which replaces values wholesale (copy-with-update pattern). +nonisolated public struct EnvironmentState: EncounterParticipant, Sendable, Equatable, Hashable { public let id: UUID /// The slug identifying this environment in the ``Compendium``. public let environmentID: String @@ -35,11 +35,11 @@ nonisolated public struct EnvironmentSlot: EncounterParticipant, Sendable, Equat self.isActive = isActive } - /// Returns a copy of this slot with the specified mutable fields replaced. + /// Returns a copy of this value with the specified mutable fields replaced. /// /// Omit any parameter to preserve the existing value. - public func applying(isActive: Bool? = nil) -> EnvironmentSlot { - EnvironmentSlot( + public func applying(isActive: Bool? = nil) -> EnvironmentState { + EnvironmentState( id: id, environmentID: environmentID, isActive: isActive ?? self.isActive diff --git a/Sources/DHKit/PlayerSlot.swift b/Sources/DHKit/PlayerState.swift similarity index 92% rename from Sources/DHKit/PlayerSlot.swift rename to Sources/DHKit/PlayerState.swift index d4b6dbd..1b39805 100644 --- a/Sources/DHKit/PlayerSlot.swift +++ b/Sources/DHKit/PlayerState.swift @@ -1,5 +1,5 @@ // -// PlayerSlot.swift +// PlayerState.swift // DHKit // // A player character participant in a live encounter. @@ -14,17 +14,17 @@ // import DHModels -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif /// A player character participant in a live encounter. /// /// Tracks combat-relevant PC stats the GM needs to resolve hits and /// track health during play. The full character sheet remains with the player. -nonisolated public struct PlayerSlot: CombatParticipant, Sendable, Equatable, Hashable { +nonisolated public struct PlayerState: CombatParticipant, Sendable, Equatable, Hashable { public let id: UUID public let name: String @@ -83,7 +83,7 @@ nonisolated public struct PlayerSlot: CombatParticipant, Sendable, Equatable, Ha self.conditions = conditions } - /// Returns a copy of this slot with the specified mutable fields replaced. + /// Returns a copy of this value with the specified mutable fields replaced. /// /// Omit any parameter to preserve the existing value. This is the preferred /// way to produce updated copies; it avoids repeating every unchanged field @@ -93,8 +93,8 @@ nonisolated public struct PlayerSlot: CombatParticipant, Sendable, Equatable, Ha currentStress: Int? = nil, currentArmorSlots: Int? = nil, conditions: Set? = nil - ) -> PlayerSlot { - PlayerSlot( + ) -> PlayerState { + PlayerState( id: id, name: name, maxHP: maxHP, currentHP: currentHP ?? self.currentHP, maxStress: maxStress, currentStress: currentStress ?? self.currentStress, diff --git a/Sources/DHKit/SessionRegistry.swift b/Sources/DHKit/SessionRegistry.swift index f40f7c1..47aa4f8 100644 --- a/Sources/DHKit/SessionRegistry.swift +++ b/Sources/DHKit/SessionRegistry.swift @@ -11,13 +11,13 @@ // import DHModels -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import Observation +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + /// In-memory registry of live ``EncounterSession`` objects, keyed by definition ID. /// /// Inject once at app launch: diff --git a/Sources/DHModels/Adversary.swift b/Sources/DHModels/Adversary.swift index 5fff7bf..eb98565 100644 --- a/Sources/DHModels/Adversary.swift +++ b/Sources/DHModels/Adversary.swift @@ -13,10 +13,10 @@ // See docs/data-schema.md for full field reference and source notes. // -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif // MARK: - AdversaryType @@ -145,7 +145,7 @@ nonisolated public struct EncounterFeature: Codable, Identifiable, Sendable, Equ /// /// This is a **catalog model** — it represents the static definition of an /// adversary, not a live instance being tracked in an encounter. -/// See ``AdversarySlot`` in `EncounterSession.swift` for the runtime type. +/// See ``AdversaryState`` in `EncounterSession.swift` for the runtime type. /// /// ## JSON Compatibility /// The `thresholds` field is stored in community JSON as a single @@ -225,8 +225,9 @@ nonisolated public struct Adversary: Codable, Identifiable, Sendable, Equatable, /// Derives a URL-safe slug from a display name, e.g. "Acid Burrower" → "acid-burrower". private static func slug(_ name: String) -> String { name.lowercased() - .components(separatedBy: CharacterSet.alphanumerics.inverted) + .split(whereSeparator: { !$0.isLetter && !$0.isNumber }) .filter { !$0.isEmpty } + .map { String($0) } .joined(separator: "-") } @@ -294,7 +295,7 @@ nonisolated public struct Adversary: Codable, Identifiable, Sendable, Equatable, let parts = raw .split(separator: "/") - .map { $0.trimmingCharacters(in: .whitespaces) } + .map { String($0).filter { !$0.isWhitespace } } guard parts.count == 2 else { throw DecodingError.dataCorruptedError( forKey: .thresholds, in: c, diff --git a/Sources/DHModels/Condition.swift b/Sources/DHModels/Condition.swift index 4231263..73153fc 100644 --- a/Sources/DHModels/Condition.swift +++ b/Sources/DHModels/Condition.swift @@ -13,10 +13,10 @@ // Per the SRD, the same condition cannot stack on a target. // -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif /// A condition that can be applied to a combatant in a Daggerheart encounter. diff --git a/Sources/DHModels/ContentFingerprint.swift b/Sources/DHModels/ContentFingerprint.swift index 59b0049..4d5b608 100644 --- a/Sources/DHModels/ContentFingerprint.swift +++ b/Sources/DHModels/ContentFingerprint.swift @@ -6,10 +6,10 @@ // Stored on ContentSource after each successful fetch. // -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif /// A combined fingerprint for a downloaded content pack. diff --git a/Sources/DHModels/ContentSource.swift b/Sources/DHModels/ContentSource.swift index ec7adbf..524e516 100644 --- a/Sources/DHModels/ContentSource.swift +++ b/Sources/DHModels/ContentSource.swift @@ -7,10 +7,10 @@ // Persisted to Application Support so the source list survives app restarts. // -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif /// A registered content source for community adversary and environment packs. @@ -126,8 +126,8 @@ nonisolated public struct ContentSource: Codable, Identifiable, Equatable, Hasha /// - 10th failure → capped at 7 days public func recordingFailure(at date: Date = .now) -> ContentSource { var updated = self - let exponent = Double(consecutiveFailures) // starts at 0 on first failure - let hours = pow(2.0, exponent) // 1, 2, 4, 8, … + let shiftAmount = min(consecutiveFailures, 62) + let hours = Double(1 << shiftAmount) // 1, 2, 4, 8, … let delay = min(hours * 3_600, 7 * 24 * 3_600) // cap at 7 days updated.consecutiveFailures += 1 updated.nextAllowedFetch = date.addingTimeInterval(delay) diff --git a/Sources/DHModels/ContentStoreError.swift b/Sources/DHModels/ContentStoreError.swift index 12cc86f..b72291a 100644 --- a/Sources/DHModels/ContentStoreError.swift +++ b/Sources/DHModels/ContentStoreError.swift @@ -5,10 +5,10 @@ // Typed errors for ContentStore, ContentFetcher, and ContentWriter. // -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif /// Errors produced by the content loading and update pipeline. @@ -29,15 +29,15 @@ nonisolated public enum ContentStoreError: Error, LocalizedError, Sendable { public var errorDescription: String? { switch self { case .fetchThrottled(let id, let until): - return "Source '\(id)' is throttled until \(until.formatted(.dateTime))." + return "Source '\(id)' is throttled until \(until.description)." case .networkError(let id, let error): - return "Network error for '\(id)': \(error.localizedDescription)" + return "Network error for '\(id)': \(error)" case .decodingFailed(let id, let error): - return "Decode failed for '\(id)': \(error.localizedDescription)" + return "Decode failed for '\(id)': \(error)" case .writeFailed(let id, let error): - return "Write failed for '\(id)': \(error.localizedDescription)" + return "Write failed for '\(id)': \(error)" case .readFailed(let id, let error): - return "Read failed for '\(id)': \(error.localizedDescription)" + return "Read failed for '\(id)': \(error)" case .invalidContent(let id, let reason): return "Invalid content from '\(id)': \(reason)" } diff --git a/Sources/DHModels/DHPackContent.swift b/Sources/DHModels/DHPackContent.swift index ad5a00b..e6cc1db 100644 --- a/Sources/DHModels/DHPackContent.swift +++ b/Sources/DHModels/DHPackContent.swift @@ -7,10 +7,10 @@ // compatible with the seansbox/daggerheart-srd format (see ADR-0024). // -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif #if canImport(UniformTypeIdentifiers) diff --git a/Sources/DHModels/DaggerheartEnvironment.swift b/Sources/DHModels/DaggerheartEnvironment.swift index 8e91380..cb085fa 100644 --- a/Sources/DHModels/DaggerheartEnvironment.swift +++ b/Sources/DHModels/DaggerheartEnvironment.swift @@ -15,10 +15,10 @@ // See docs/data-schema.md for the full field reference. // -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif /// A Daggerheart environment — a scene element with features but no HP or Stress. @@ -64,8 +64,9 @@ nonisolated public struct DaggerheartEnvironment: Codable, Identifiable, Sendabl id = try c.decodeIfPresent(String.self, forKey: .id) ?? name.lowercased() - .components(separatedBy: CharacterSet.alphanumerics.inverted) + .split(whereSeparator: { !$0.isLetter && !$0.isNumber }) .filter { !$0.isEmpty } + .map { String($0) } .joined(separator: "-") // Normalize source to lowercase so "SRD", "srd", "Homebrew", etc. all compare equal. source = (try c.decodeIfPresent(String.self, forKey: .source) ?? "srd").lowercased() diff --git a/Sources/DHModels/DaggerheartModels.docc/DaggerheartModels.md b/Sources/DHModels/DaggerheartModels.docc/DaggerheartModels.md index 61d82e4..8858247 100644 --- a/Sources/DHModels/DaggerheartModels.docc/DaggerheartModels.md +++ b/Sources/DHModels/DaggerheartModels.docc/DaggerheartModels.md @@ -30,7 +30,7 @@ ecosystem. ### Encounters - ``EncounterDefinition`` -- ``PlayerSlot`` +- ``PlayerConfig`` - ``DifficultyBudget`` - ``Condition`` diff --git a/Sources/DHModels/DaggerheartModels.docc/Resources/catalog.svg b/Sources/DHModels/DaggerheartModels.docc/Resources/catalog.svg index e1cc068..4efad7b 100644 --- a/Sources/DHModels/DaggerheartModels.docc/Resources/catalog.svg +++ b/Sources/DHModels/DaggerheartModels.docc/Resources/catalog.svg @@ -1 +1 @@ -

role

attackRange

features

kind

features

1

1

0..*

0..*

Adversary

+String id

+String name

+String source

+Int tier

+AdversaryType role

+String flavorText

+String? motivesAndTactics

+Int difficulty

+Int thresholdMajor

+Int thresholdSevere

+Int hp

+Int stress

+String attackModifier

+String attackName

+AttackRange attackRange

+String damage

+String? experience

+[EncounterFeature] features

+Bool isHomebrew

«enumeration»

AdversaryType

bruiser

horde

leader

minion

ranged

skulk

social

solo

standard

support

«enumeration»

AttackRange

melee

veryClose

close

far

veryFar

«enumeration»

FeatureType

action

reaction

passive

+inferred(from:) : FeatureType

EncounterFeature

+String id

+String name

+String text

+FeatureType kind

DaggerheartEnvironment

+String id

+String name

+String source

+String flavorText

+[EncounterFeature] features

+Bool isHomebrew

«enumeration»

Condition

hidden

restrained

vulnerable

+String displayName

custom(String)

\ No newline at end of file +

role

attackRange

features

kind

features

1

1

0..*

0..*

Adversary

+String id

+String name

+String source

+Int tier

+AdversaryType role

+String flavorText

+String? motivesAndTactics

+Int difficulty

+Int thresholdMajor

+Int thresholdSevere

+Int hp

+Int stress

+String attackModifier

+String attackName

+AttackRange attackRange

+String damage

+String? experience

+[EncounterFeature] features

+Bool isHomebrew

«enumeration»

AdversaryType

bruiser

horde

leader

minion

ranged

skulk

social

solo

standard

support

«enumeration»

AttackRange

melee

veryClose

close

far

veryFar

«enumeration»

FeatureType

action

reaction

passive

+inferred(from:) : FeatureType

EncounterFeature

+String id

+String name

+String text

+FeatureType kind

DaggerheartEnvironment

+String id

+String name

+String source

+String flavorText

+[EncounterFeature] features

+Bool isHomebrew

«enumeration»

Condition

hidden

restrained

vulnerable

+String displayName

custom(String)

\ No newline at end of file diff --git a/Sources/DHModels/DaggerheartModels.docc/Resources/content.svg b/Sources/DHModels/DaggerheartModels.docc/Resources/content.svg index 0161fdf..0afde73 100644 --- a/Sources/DHModels/DaggerheartModels.docc/Resources/content.svg +++ b/Sources/DHModels/DaggerheartModels.docc/Resources/content.svg @@ -1 +1 @@ -

adversaries

environments

fingerprint

1

1

0..*

0..*

DHPackContent

+[Adversary] adversaries

+[DaggerheartEnvironment] environments

«catalog type — see catalog diagram»

Adversary

«catalog type — see catalog diagram»

DaggerheartEnvironment

ContentSource

+UUID id

+String? displayName

+URL? url

+Date addedAt

+Date? lastFetchedAt

+ContentFingerprint? fingerprint

+Int consecutiveFailures

+Bool isLocalImport

+Bool isThrottled(at: Date)

+Date nextAllowedFetch(at: Date)

+ContentSource recordingFailure(at: Date)

+ContentSource recordingSuccess(fingerprint:at:)

ContentFingerprint

+String sha256

+String? etag

«enumeration»

ContentStoreError

fetchThrottled

networkError

decodingFailed

writeFailed

readFailed

invalidContent

\ No newline at end of file +

adversaries

environments

fingerprint

1

1

0..*

0..*

DHPackContent

+[Adversary] adversaries

+[DaggerheartEnvironment] environments

«catalog type — see catalog diagram»

Adversary

«catalog type — see catalog diagram»

DaggerheartEnvironment

ContentSource

+UUID id

+String? displayName

+URL? url

+Date addedAt

+Date? lastFetchedAt

+ContentFingerprint? fingerprint

+Int consecutiveFailures

+Bool isLocalImport

+Bool isThrottled(at: Date)

+Date nextAllowedFetch(at: Date)

+ContentSource recordingFailure(at: Date)

+ContentSource recordingSuccess(fingerprint:at:)

ContentFingerprint

+String sha256

+String? etag

«enumeration»

ContentStoreError

fetchThrottled

networkError

decodingFailed

writeFailed

readFailed

invalidContent

\ No newline at end of file diff --git a/Sources/DHModels/DaggerheartModels.docc/Resources/encounter.svg b/Sources/DHModels/DaggerheartModels.docc/Resources/encounter.svg index a864a75..f0ccc01 100644 --- a/Sources/DHModels/DaggerheartModels.docc/Resources/encounter.svg +++ b/Sources/DHModels/DaggerheartModels.docc/Resources/encounter.svg @@ -1 +1 @@ -

playerConfigs

returns

returns

1

0..*

EncounterDefinition

+UUID id

+String name

+[String] adversaryIDs

+[String] environmentIDs

+[PlayerConfig] playerConfigs

+String gmNotes

+Date createdAt

+Date modifiedAt

PlayerConfig

+UUID id

+String name

+Int maxHP

+Int maxStress

+Int evasion

+Int thresholdMajor

+Int thresholdSevere

+Int armorSlots

«utility»

DifficultyBudget

+cost(for: AdversaryType) : Int

+baseBudget(playerCount:) : Int

+totalCost(for: [AdversaryType]) : Int

+rating(adversaryTypes:playerCount:budgetAdjustment:) : Rating

+suggestedAdjustments(adversaryTypes:) : Set<Adjustment>

DifficultyBudget_Rating

+Int cost

+Int budget

+Int remaining

«enumeration»

DifficultyBudget_Adjustment

easierFight

multipleSolos

boostedDamage

lowerTierAdversary

noBigThreats

harderFight

+Int pointValue

+String displayName

\ No newline at end of file +

playerConfigs

returns

returns

1

0..*

EncounterDefinition

+UUID id

+String name

+[String] adversaryIDs

+[String] environmentIDs

+[PlayerConfig] playerConfigs

+String gmNotes

+Date createdAt

+Date modifiedAt

PlayerConfig

+UUID id

+String name

+Int maxHP

+Int maxStress

+Int evasion

+Int thresholdMajor

+Int thresholdSevere

+Int armorSlots

«utility»

DifficultyBudget

+cost(for: AdversaryType) : Int

+baseBudget(playerCount:) : Int

+totalCost(for: [AdversaryType]) : Int

+rating(adversaryTypes:playerCount:budgetAdjustment:) : Rating

+suggestedAdjustments(adversaryTypes:) : Set<Adjustment>

DifficultyBudget_Rating

+Int cost

+Int budget

+Int remaining

«enumeration»

DifficultyBudget_Adjustment

easierFight

multipleSolos

boostedDamage

lowerTierAdversary

noBigThreats

harderFight

+Int pointValue

+String displayName

\ No newline at end of file diff --git a/Sources/DHModels/DifficultyBudget.swift b/Sources/DHModels/DifficultyBudget.swift index 688117d..35f2884 100644 --- a/Sources/DHModels/DifficultyBudget.swift +++ b/Sources/DHModels/DifficultyBudget.swift @@ -13,10 +13,10 @@ // Then apply adjustments and spend points to add adversaries by type. // -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif /// Pure-function namespace for computing Daggerheart encounter difficulty diff --git a/Sources/DHModels/EncounterDefinition.swift b/Sources/DHModels/EncounterDefinition.swift index a1e98eb..373139d 100644 --- a/Sources/DHModels/EncounterDefinition.swift +++ b/Sources/DHModels/EncounterDefinition.swift @@ -11,23 +11,23 @@ // // Catalog vs. Runtime Split: // - Definition stores adversary/environment IDs (references into the Compendium). -// - Session resolves those IDs into live AdversarySlot and EnvironmentSlot +// - Session resolves those IDs into live AdversaryState and EnvironmentState // instances with mutable HP, Stress, and condition tracking. // -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif // MARK: - PlayerConfig /// Configuration for a single player character in an encounter definition. /// -/// This is the `Codable`, value-type counterpart of ``PlayerSlot``. +/// This is the `Codable`, value-type counterpart of ``PlayerState``. /// When an ``EncounterSession`` is started from a definition, each -/// `PlayerConfig` becomes a ``PlayerSlot`` with fresh runtime state. +/// `PlayerConfig` becomes a ``PlayerState`` with fresh runtime state. nonisolated public struct PlayerConfig: Codable, Sendable, Equatable, Hashable, Identifiable { public let id: UUID public let name: String diff --git a/Sources/validate-dhpack/main.swift b/Sources/validate-dhpack/main.swift index c0ed20f..2100e6c 100644 --- a/Sources/validate-dhpack/main.swift +++ b/Sources/validate-dhpack/main.swift @@ -1,9 +1,9 @@ import ArgumentParser import DHModels -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation + +#if canImport(FoundationEssentials) + import FoundationEssentials #endif // validate-dhpack — validates one or more .dhpack files against the DaggerheartModels schema. @@ -40,7 +40,7 @@ struct ValidateDHPack: ParsableCommand { ) } catch { FileHandle.standardError.write( - Data("\(path): FAILED — \(error.localizedDescription)\n".utf8)) + Data("\(path): FAILED — \(error)\n".utf8)) failed = true } } diff --git a/Tests/DHKitTests/CompendiumTests.swift b/Tests/DHKitTests/CompendiumTests.swift index 9d90468..62f6bec 100644 --- a/Tests/DHKitTests/CompendiumTests.swift +++ b/Tests/DHKitTests/CompendiumTests.swift @@ -10,15 +10,15 @@ // import DHModels -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import Testing @testable import DHKit +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + // MARK: - Compendium async load @MainActor struct CompendiumLoadTests { diff --git a/Tests/DHKitTests/EncounterSessionTests.swift b/Tests/DHKitTests/EncounterSessionTests.swift index 3eb5f31..d482f3e 100644 --- a/Tests/DHKitTests/EncounterSessionTests.swift +++ b/Tests/DHKitTests/EncounterSessionTests.swift @@ -2,20 +2,20 @@ // EncounterSessionTests.swift // DaggerheartKitTests // -// Unit tests for EncounterSession mutations, AdversarySlot stat snapshots, -// PlayerSlot session integration, and EncounterSession factory (start from definition). +// Unit tests for EncounterSession mutations, AdversaryState stat snapshots, +// PlayerState session integration, and EncounterSession factory (start from definition). // import DHModels -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import Testing @testable import DHKit +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + // MARK: - EncounterSession @MainActor struct EncounterSessionTests { @@ -200,9 +200,9 @@ import Testing } } -// MARK: - PlayerSlot Session Integration +// MARK: - PlayerState Session Integration -@MainActor struct PlayerSlotSessionTests { +@MainActor struct PlayerStateSessionTests { private func makeSession() -> EncounterSession { EncounterSession(name: "Test Encounter") @@ -227,8 +227,8 @@ import Testing ) } - private func makePlayer() -> PlayerSlot { - PlayerSlot( + private func makePlayer() -> PlayerState { + PlayerState( name: "Aldric", maxHP: 6, maxStress: 6, @@ -339,7 +339,7 @@ import Testing @Test func markArmorSlotClampsToZero() { let session = makeSession() var player = makePlayer() - player = PlayerSlot( + player = PlayerState( name: player.name, maxHP: player.maxHP, maxStress: player.maxStress, evasion: player.evasion, thresholdMajor: player.thresholdMajor, thresholdSevere: player.thresholdSevere, armorSlots: 1 @@ -472,9 +472,9 @@ import Testing } } -// MARK: - AdversarySlot stat snapshot +// MARK: - AdversaryState stat snapshot -@MainActor struct AdversarySlotSnapshotTests { +@MainActor struct AdversaryStateSnapshotTests { private func makeSoldier() -> Adversary { Adversary( @@ -488,7 +488,7 @@ import Testing @Test func slotSnapshotsMaxHPAndMaxStress() { let soldier = makeSoldier() - let slot = AdversarySlot(adversaryID: soldier.id, maxHP: soldier.hp, maxStress: soldier.stress) + let slot = AdversaryState(adversaryID: soldier.id, maxHP: soldier.hp, maxStress: soldier.stress) #expect(slot.maxHP == 6) #expect(slot.maxStress == 3) } diff --git a/Tests/DHKitTests/EncounterStoreTests.swift b/Tests/DHKitTests/EncounterStoreTests.swift index 893b44a..a5662db 100644 --- a/Tests/DHKitTests/EncounterStoreTests.swift +++ b/Tests/DHKitTests/EncounterStoreTests.swift @@ -7,15 +7,15 @@ // import DHModels -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import Testing @testable import DHKit +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + // MARK: - EncounterStoreError struct EncounterStoreErrorTests { diff --git a/Tests/DHKitTests/SessionRegistryTests.swift b/Tests/DHKitTests/SessionRegistryTests.swift index 57b122f..3c1d464 100644 --- a/Tests/DHKitTests/SessionRegistryTests.swift +++ b/Tests/DHKitTests/SessionRegistryTests.swift @@ -7,15 +7,15 @@ // import DHModels -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import Testing @testable import DHKit +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + @MainActor struct SessionRegistryTests { private func makeCompendium() -> Compendium { diff --git a/Tests/DHKitTests/SlotTests.swift b/Tests/DHKitTests/StateTests.swift similarity index 74% rename from Tests/DHKitTests/SlotTests.swift rename to Tests/DHKitTests/StateTests.swift index ed5738d..b624357 100644 --- a/Tests/DHKitTests/SlotTests.swift +++ b/Tests/DHKitTests/StateTests.swift @@ -1,26 +1,26 @@ // -// SlotTests.swift +// StateTests.swift // DHKitTests // -// Unit tests for the slot value types: PlayerSlot, AdversarySlot, EnvironmentSlot. +// Unit tests for the session state value types: PlayerState, AdversaryState, EnvironmentState. // import DHModels -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import Testing @testable import DHKit -// MARK: - PlayerSlot +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + +// MARK: - PlayerState -struct PlayerSlotTests { +struct PlayerStateTests { @Test func playerSlotInitializesWithCorrectDefaults() { - let slot = PlayerSlot( + let slot = PlayerState( name: "Aldric", maxHP: 6, maxStress: 6, @@ -38,11 +38,11 @@ struct PlayerSlotTests { @Test func playerSlotEquality() { let id = UUID() - let slot1 = PlayerSlot( + let slot1 = PlayerState( id: id, name: "A", maxHP: 6, maxStress: 6, evasion: 10, thresholdMajor: 5, thresholdSevere: 10, armorSlots: 2 ) - let slot2 = PlayerSlot( + let slot2 = PlayerState( id: id, name: "A", maxHP: 6, maxStress: 6, evasion: 10, thresholdMajor: 5, thresholdSevere: 10, armorSlots: 2 ) @@ -50,12 +50,12 @@ struct PlayerSlotTests { } } -// MARK: - AdversarySlot +// MARK: - AdversaryState -struct AdversarySlotTests { +struct AdversaryStateTests { @Test func adversarySlotInitializesWithCorrectDefaults() { - let slot = AdversarySlot(adversaryID: "ironguard-soldier", maxHP: 6, maxStress: 3) + let slot = AdversaryState(adversaryID: "ironguard-soldier", maxHP: 6, maxStress: 3) #expect(slot.currentHP == 6) #expect(slot.currentStress == 0) #expect(slot.isDefeated == false) @@ -71,7 +71,7 @@ struct AdversarySlotTests { hp: 4, stress: 2, attackModifier: "+1", attackName: "Dagger", attackRange: .veryClose, damage: "1d6 phy" ) - let slot = AdversarySlot(from: adversary, customName: "Grim") + let slot = AdversaryState(from: adversary, customName: "Grim") #expect(slot.adversaryID == "bandit") #expect(slot.maxHP == 4) #expect(slot.maxStress == 2) @@ -79,7 +79,7 @@ struct AdversarySlotTests { } @Test func adversarySlotApplyingPreservesUnchangedFields() { - let slot = AdversarySlot(adversaryID: "orc", maxHP: 8, maxStress: 4) + let slot = AdversaryState(adversaryID: "orc", maxHP: 8, maxStress: 4) let updated = slot.applying(currentHP: 5) #expect(updated.currentHP == 5) #expect(updated.maxHP == 8) @@ -88,18 +88,18 @@ struct AdversarySlotTests { } } -// MARK: - EnvironmentSlot +// MARK: - EnvironmentState -struct EnvironmentSlotTests { +struct EnvironmentStateTests { @Test func environmentSlotDefaultsToActive() { - let slot = EnvironmentSlot(environmentID: "arcane-storm") + let slot = EnvironmentState(environmentID: "arcane-storm") #expect(slot.isActive == true) #expect(slot.environmentID == "arcane-storm") } @Test func environmentSlotApplyingTogglesActive() { - let slot = EnvironmentSlot(environmentID: "collapsing-bridge", isActive: true) + let slot = EnvironmentState(environmentID: "collapsing-bridge", isActive: true) let deactivated = slot.applying(isActive: false) #expect(deactivated.isActive == false) #expect(deactivated.id == slot.id) diff --git a/Tests/DHModelsTests/ContentModelTests.swift b/Tests/DHModelsTests/ContentModelTests.swift index ed19424..dbf5e9c 100644 --- a/Tests/DHModelsTests/ContentModelTests.swift +++ b/Tests/DHModelsTests/ContentModelTests.swift @@ -7,15 +7,15 @@ // All tested types are pure value types with no I/O dependencies. // -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import Testing @testable import DHModels +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + // MARK: - ContentSource backoff struct ContentSourceBackoffTests { diff --git a/Tests/DHModelsTests/ModelTests.swift b/Tests/DHModelsTests/ModelTests.swift index 3b73406..0f40519 100644 --- a/Tests/DHModelsTests/ModelTests.swift +++ b/Tests/DHModelsTests/ModelTests.swift @@ -7,15 +7,15 @@ // DifficultyBudget, DaggerheartEnvironment. // -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import Testing @testable import DHModels +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + // MARK: - Adversary Decoding struct AdversaryDecodingTests { diff --git a/Tests/DHModelsTests/SRDDecodeTests.swift b/Tests/DHModelsTests/SRDDecodeTests.swift index d23b116..b88dbf8 100644 --- a/Tests/DHModelsTests/SRDDecodeTests.swift +++ b/Tests/DHModelsTests/SRDDecodeTests.swift @@ -9,15 +9,15 @@ // Bundle.module resolves them correctly on all platforms including Linux. // -#if canImport(FoundationEssentials) -import FoundationEssentials -#else import Foundation -#endif import Testing @testable import DHModels +#if canImport(FoundationEssentials) + import FoundationEssentials +#endif + struct SRDDecodeTests { private static func url(forResource name: String) throws -> URL { diff --git a/diagrams/DHKit/participants.mmd b/diagrams/DHKit/participants.mmd index 9367a96..09cbac8 100644 --- a/diagrams/DHKit/participants.mmd +++ b/diagrams/DHKit/participants.mmd @@ -18,8 +18,8 @@ classDiagram EncounterParticipant <|-- CombatParticipant - %% ── Slot value types (nonisolated) ──────────────────────────────────── - class AdversarySlot { + %% ── Session state value types (nonisolated) ─────────────────────────── + class AdversaryState { +UUID id +String adversaryID +String? customName @@ -30,10 +30,10 @@ classDiagram +Bool isDefeated +Set~Condition~ conditions +init(from: Adversary, customName:) - +applying(currentHP:currentStress:isDefeated:conditions:) AdversarySlot + +applying(currentHP:currentStress:isDefeated:conditions:) AdversaryState } - class PlayerSlot { + class PlayerState { +UUID id +String name +Int maxHP @@ -46,16 +46,16 @@ classDiagram +Int armorSlots +Int currentArmorSlots +Set~Condition~ conditions - +applying(currentHP:currentStress:currentArmorSlots:conditions:) PlayerSlot + +applying(currentHP:currentStress:currentArmorSlots:conditions:) PlayerState } - class EnvironmentSlot { + class EnvironmentState { +UUID id +String environmentID +Bool isActive - +applying(isActive:) EnvironmentSlot + +applying(isActive:) EnvironmentState } - CombatParticipant <|.. AdversarySlot - CombatParticipant <|.. PlayerSlot - EncounterParticipant <|.. EnvironmentSlot + CombatParticipant <|.. AdversaryState + CombatParticipant <|.. PlayerState + EncounterParticipant <|.. EnvironmentState diff --git a/diagrams/DHKit/stores.mmd b/diagrams/DHKit/stores.mmd index 27f4f70..e43bbb9 100644 --- a/diagrams/DHKit/stores.mmd +++ b/diagrams/DHKit/stores.mmd @@ -6,19 +6,19 @@ classDiagram <> +UUID id +String name - +[AdversarySlot] adversarySlots - +[PlayerSlot] playerSlots - +[EnvironmentSlot] environmentSlots + +[AdversaryState] adversarySlots + +[PlayerState] playerSlots + +[EnvironmentState] environmentSlots +Int fearPool +Int hopePool +UUID? spotlightedSlotID +Int spotlightCount +String gmNotes - +[AdversarySlot] activeAdversaries + +[AdversaryState] activeAdversaries +Bool isOver +add(adversary: Adversary, customName:) +add(environment: DaggerheartEnvironment) - +add(player: PlayerSlot) + +add(player: PlayerState) +removeAdversary(withID:) +removePlayer(withID:) +spotlight(id:) @@ -38,14 +38,14 @@ classDiagram +make(from: EncounterDefinition, using: Compendium) EncounterSession$ } - %% slot types shown as aggregated members only — see participants diagram - class AdversarySlot { +UUID id } - class PlayerSlot { +UUID id } - class EnvironmentSlot { +UUID id } + %% session state types shown as aggregated members only — see participants diagram + class AdversaryState { +UUID id } + class PlayerState { +UUID id } + class EnvironmentState { +UUID id } - EncounterSession "1" *-- "0..*" AdversarySlot : adversarySlots - EncounterSession "1" *-- "0..*" PlayerSlot : playerSlots - EncounterSession "1" *-- "0..*" EnvironmentSlot : environmentSlots + EncounterSession "1" *-- "0..*" AdversaryState : adversarySlots + EncounterSession "1" *-- "0..*" PlayerState : playerSlots + EncounterSession "1" *-- "0..*" EnvironmentState : environmentSlots %% ── Compendium ─────────────────────────────────────────────────────── class Compendium {