cthangover - documentation
Plain-text dump for AI agents: 60 markdown documents.

===== DOCUMENTATION =====

===== DOC docs/index.md: Documentation =====
[image: cthangover logo]


Welcome to the cthangover documentation. Here you'll find guides, code examples, and configuration references for mod and core development.

## Kernel and game cores

cthangover is not tied to a single genre. The C# kernel (`Core/`) provides genre-neutral infrastructure only: scenes and scenarios with the DSL, the mod registry, saves, resources, UI hosting, events, audio, and pluggable battle rulesets. It makes no assumptions about dialogue, portraits, or the shape of the game.

A **game core** is an ordinary mod that turns the kernel into a concrete game. `cthangover_game` is the reference core shipped with the project: it defines characters, avatars, backgrounds, quests, items, and scenarios for a dialogue-driven story game. It is only one possible core — the kernel does not require it, and it uses the same public interfaces available to every mod.

Any genre can be built the same way. For example, a 2D sprite RPG with tile movement, turn-based encounters, and its own inventory UI can be plugged in as a core mod: it reuses the kernel (scenes, saves, events, resource pipeline) and brings its own assets, scenarios, actions, and UI scenes. The same approach works for other genres.

| Layer | Location | Responsibility |
|---|---|---|
| Kernel | `Core/` | Genre-neutral services: scenes, scenario DSL, mods, saves, events, resources, UI, audio |
| Game core | `mods/<core>` | Defines a concrete game: content, rules, scenes, actions, UI |
| Gameplay mods | `mods/<mod>` | Extend a core or the kernel: patches, assets, custom actions, battle rulesets |

A core can be disabled or replaced. Mods that depend on a specific core must declare it in `depends` — see Configs — manifest.json. The kernel itself never depends on a core.

## Mod assets & data

| Topic | Description |
|---|---|
| Avatars | Character portrait images |
| Backgrounds | Scene background images and depth maps |
| Characters | Character JSON definitions and attributes |
| Interactive Objects | Clickable scene objects with hit areas and actions |
| Items | Item system — consumables, resources, recipes |
| Property Patches | Extend entity properties from another mod without redefining JSON |
| Skills | Card-based skill system |
| Sounds | Sound effect definitions |
| Shaders | Custom shader effects |

## Mod configuration

| Topic | Description |
|---|---|
| Configs — manifest.json | Mod manifest reference |
| Game Config | Default game settings |
| Mod Config | Mod system cache and file discovery |
| Catalog & Network | Online mod catalog schema, merging, and export |
| Collections | Curated mod sets implementing a mechanic, shown as a dependency graph |

## Scenarios & DSL

| Topic | Description |
|---|---|
| Scenes | Scene definitions and lifecycle |
| Runtime TSCN Scenes | Loading Godot `.tscn` scene files at runtime from mods |
| Scenarios | `.scenario` format and metadata |
| Scenario Actions | `action` command reference |
| DSL Commands | Full command reference (30 commands) |
| Conditions | Quest condition expression language |

## Gameplay systems

| Topic | Description |
|---|---|
| Battle | Battle system architecture and custom battle mods |
| Quests | Quest definitions and progression |
| Status Effects | Buff, debuff, and stun effect definitions |
| Time | In-game clock, day/night phases, lighting |
| Map & Travel Zones | World map, travel zones, map DSL actions, in-game map editor |
| Recruiting | Character recruitment system |
| Save System | Save/load data structure and persistence |
| Mod Settings | Per-mod settings in the in-game menu |

## Audio

| Topic | Description |
|---|---|
| Music & Playlists | Background music and playlist configuration |
| Playlists — JSON Reference | Detailed playlist schema |

## Mod development

| Topic | Description |
|---|---|
| Source Code (C#) | C# in mods — Roslyn compilation, interfaces |
| GDScript in Mods | Scenario actions, conditions, battle, items, lifecycle |
| Developer Tools | Custom editor windows and toolbar integration |
| Locale | Localization system |
| UI Wrappers | UI template wrapper system |
| Scene Event Metadata | `[SceneEvent]` attribute for declarative hooks |
| ZIP Distribution | Mod packaging and distribution |

## Resources — Pluggable Infrastructure

| Topic | Description |
|---|---|
| Resources Overview | Scanner → Loader → Provider pipeline |
| Providers | `IModFileProvider` — folder, zip, and custom backends |
| Scanners | `IModScanner` — local dirs, HTTP catalogs, custom sources |
| Loaders | `IModLoader` — folder, zip, remote download, archive formats |
| Discovery Registry | `ModDiscoveryRegistry` — auto-discovery and LIFO lookup |

===== DOC docs/mods/avatars/index.md: Avatars =====
Character portrait images displayed during dialogue scenes, positioned via `first=` (left, player) and `second=` (right, NPC) parameters.

## Directory structure

```filestree
avatars/
└── {character_name}/
    └── {expression}.png
```

## Real example — Murakami

From `mods/cthangover_murakami/avatars/murakami/`:

```filestree
murakami/
├── normal_1.png      # neutral expression
├── normal_2.png      # neutral variant
├── fear_1.png        # scared
└── fear_2.png        # terrified
```

The compile-time catalog (`Avatars.cs`) additionally lists `murakami/normal_3`. Expressions are referenced as `<character>/<expression>` without the `.png` extension in DSL parameters (`first=` / `second=`).

## Usage in scenarios

Avatars appear in dialogue lines through the `text` command:

```scenario
text "- Help me..." key=murakami/help second=murakami/fear_1
text "- What are you doing here alone?" key=murakami/alone first=marao/think
```

- `first=` — left-side speaker (usually the player)
- `second=` — right-side speaker (usually the NPC)
- The value is `{character}/{expression}` — no `.png` extension

## Adding a new avatar set

1. Create `avatars/{character_name}/` in your mod
2. Add `.png` files for each expression you need
3. The character's JSON definition sets `"Image"` to the base path:

```jsonc
{
  "Id": "Murakami",                                        // Unique character identifier
  "Image": "characters/player/murakami"                    // Base path to character textures
}
```

4. Use in scenarios: `first=murakami/normal_1` or `second=murakami/fear_1`

## Multiple characters in dialogue

The same expression naming convention works for any character:

```filestree
avatars/
├── marao/
│   ├── sad.png
│   ├── smile.png
│   └── think.png
└── murakami/
    ├── normal_1.png
    ├── normal_2.png
    ├── fear_1.png
    └── fear_2.png
```

Note that the on-disk set and the compile-time catalog may differ slightly: `Avatars.cs` lists additional marao expressions (`angry`, `what`, `relax`) and a murakami `normal_3`, while marao's `sad` exists only as a file. Always check the mod's avatar directory for what is actually available.

## Format requirements

- **Format:** PNG with transparency
- **Resolution:** 512×512 recommended, but any square power-of-two works
- **Naming:** lowercase, underscores for multi-word expressions (`fear_1`, `very_happy`)
- The extension is omitted in scenario files — the engine resolves `{character}/{expression}` to `avatars/{character}/{expression}.png`

===== DOC docs/mods/backgrounds/index.md: Backgrounds =====
Scene background images with optional depth maps for parallax effects and dynamic lighting.

## Directory structure

```filestree
backgrounds/
└── {scene_name}/
    ├── {image_name}.png
    └── {image_name}_depth.png   # optional depth map
```

## Real example — Murakami meeting

From `mods/cthangover_murakami/backgrounds/town/meetingmurakami/`:

```filestree
meetingmurakami/
├── 00002-1920617752.png       # wolf tracks at the door
├── 00002-1920617752_depth.png
├── 00014-1724512349.png       # Murakami at her door
├── 00014-1724512349_depth.png
├── 00024-3950100873.png
├── 00024-3950100873_depth.png
├── 00031-3535505844.png       # werewolf reveal
├── 00031-3535505844_depth.png
├── 00055-1920617752.png
├── 00055-1920617752_depth.png
├── 00078-2783395178.png       # inside, different angle
├── 00078-2783395178_depth.png
├── 00079-2783395178.png       # inside the house
├── 00079-2783395178_depth.png
├── 00136-3647329618.png       # dark room
└── 00136-3647329618_depth.png
```

Each background has a matching `_depth.png` — a grayscale image where brightness encodes the Z-depth of each pixel. The `light_set` command uses it to cast dynamic shadows.

## Usage in scenarios

```scenario
background "town/meetingmurakami/00002-1920617752"
text "[Wolf tracks lead straight to the doorstep]" key=meeting/after_wolf/tracks

background "town/meetingmurakami/00014-1724512349"
text "- Dear lady!" key=meeting/after_wolf/greeting first=marao/think
```

The path is relative to `backgrounds/` — no `.png` extension needed.

## Scene default background

Each scene definition can specify a default background that loads when entering the scene:

```jsonc
{
  "name": "home_kitchen",                                  // Scene identifier
  "defaultBackground": "home/kitchen",                     // Background image loaded when entering the scene
  "defaultScenario": "scenarios/home/home_kitchen_default.scenario" // Default scenario to run on scene enter
}
```

Mods override backgrounds by setting a higher `priority` in their `.scenario` file.

## Adding a new background

1. Create `backgrounds/{scene}/{name}.png` in your mod
2. Optionally create a `{name}_depth.png` grayscale depth map
3. Reference in scenario: `background "scene/name"`
4. If you want it as the default, update the scene JSON's `defaultBackground`

## AI-generated backgrounds workflow

The project uses AI-generated (Stable Diffusion / Flux) images for backgrounds. Typical workflow:

1. Generate a scene description prompt
2. Get the base PNG
3. Generate a depth map variant using a depth estimator or ControlNet
4. Place both files in the appropriate folder

===== DOC docs/mods/battle/index.md: Battle System =====
The battle engine is a pluggable, mod-driven system that lives entirely in the `turn_battle_base` mod. Each battle ruleset is implemented as a mod (e.g. `card_battle`, `ff_battle`) following the `IBattleCore` interface; the platform types (`BattleData`, `BattleCoreRegistry`, `BattleSceneContext`, action executors, status effects, victory screen) all ship in `turn_battle_base`, so the core only knows generic mod infrastructure. Battles are initiated from scenario scripts via `action battle.init`.

## Architecture overview

```mermaid
flowchart LR
    subgraph DSL["Scenario DSL"]
        A["action battle.init<br/>scene=<br/>enemies=<br/>quest_id=<br/>new_tag="]
    end
    subgraph Mod1["Mod / turn_battle_base"]
        B["BattleInitAction<br/>ctx.GetParam<br/>BattleService.Init<br/>Log event"]
        C["BattleRequest.Current<br/>BattleData<br/>Quest / NewTag<br/>EnemiesCards<br/>Background / DepthMap / AlbedoMap<br/>BattleSide<br/>ActiveBattleCore<br/>ReturnScene"]
        S["SceneRouteRegistry<br/>'Battle' -> scenes/battle/battle.tscn"]
    end
    subgraph Mod2["Mod (e.g. card_battle)"]
        D["IBattleCore.Init()<br/>Character panels<br/>Action executors<br/>UI widgets<br/>Status effects"]
    end
    A --> B --> C --> D
    S -.->|"switch_scene Battle"| D
```

The scenario switches to the battle view with `switch_scene Battle`; the mod registered that logical name as a scene route with the core, so `SceneManager` routes it to the mod-relative tscn (with fade transition and combat music) without any battle knowledge in the core.

## Key interfaces

### `IBattleCore` — Combat ruleset

Every battle system mod defines a class implementing `IBattleCore` (`mods/turn_battle_base/src/Adapters/IBattleCore.cs`):

| Member | Description |
|---|---|
| `Id` | Unique string ID used by `battle.set_core` |
| `ActionProvider` | Returns `IActionExecutorProvider` for overriding action executors |
| `Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx)` | Called when battle starts — sets up UI, spawns enemies |
| `Start()` | Called after Init — begins the battle loop |

Cores are discovered by `TurnBattleSceneInitializer`: it rescans mod assemblies as they announce themselves and calls `BattleCoreRegistry.RegisterAssembly`. Mods that add a core should declare `"depends": ["turn_battle_base>=1"]` so their assembly compiles after the platform.

### `IActionExecutor` — Ability resolution

Each battle action type (attack, defence, stun, item, etc.) has an executor:

| Member | Description |
|---|---|
| `ActionId` | Unique action identifier |
| `Execute(action, user, target)` | Resolves the action against target |

Executors are registered through `ActionExecutorHub`, which routes actions by ID at runtime.

GDScript mods can register executors as well — a `.gd` file with `get_action_id()` + `execute(action, user, target)` is auto-registered into the global executor registry; see GDScript in Mods for the dictionary-based contract.

### `IBattleContext` — Engine bridge

Bridge from the battle core back to the Godot scene so cores stay engine-agnostic:

| Member | Description |
|---|---|
| `ApplyDamage(source, target, rawDamage, defence)` | Applies `max(0, rawDamage - defence)` to the target's Health directly (no event — the UI layer polls) |
| `ScheduleAnimation(IBattleAnimation)` | Schedules visual feedback for playback |
| `EndTurn(BattleSide side)` | Phase signal: the given side ends its turn |
| `EndBattle(BattleSide winner)` | Ends the battle with the given winner |
| `RootNode` | Godot `Node` anchor for instantiating nodes (e.g. damage numbers) |

## Battle initialization flow

1. Scenario runs `action battle.set_core core=card_battle` — selects the ruleset by ID. The registry is a case-sensitive dictionary and **throws on an unknown ID**; built-in IDs are `card_battle` and `ff_battle`
2. Scenario runs `action battle.init scene=... enemies=... quest_id=... new_tag=...`
3. `BattleInitAction` reads dialog variables and calls `BattleService.Init()`
4. `BattleData` captures the current background
5. `BattleData.InitBattle` resolves enemy character IDs to `Character` instances via `CharacterFactory`; unknown IDs get a fallback template character (20 HP, 3 action points)
6. `BattleService.Init` stores the encounter in `BattleRequest.Current` (the mod's static hand-off slot — the core runtime data no longer carries battle state)
7. Scenario switches to `battle`, the core routes it through `SceneRouteRegistry`, and `BattleSceneContext` resolves the active `IBattleCore` and calls `Init()` then `Start()`

**Important**: `battle.init` must run *after* the background has been set in the scenario, otherwise the battle gets a null backdrop.

## Status effects

Status effects (buff, debuff, stun) are applied during battle via the `StatusEffectQueue` of each combatant. Queues are not fields of `Character` — battle code obtains them through `StatusEffectService.Of(character)`, a `ConditionalWeakTable` side table owned by the turn-battle mod. Effects have a duration in turns and expire automatically.

See Status Effects for the JSON definition format.

## Victory screen listeners (winground)

The victory screen (`Winground.tscn`, driven by `WingroundBehaviour`) exposes an extension point for mods that want to modify EXP awards, alter generated loot, or run logic right before the UI appears. Implement `IWingroundListener` (`mods/turn_battle_base/src/IWingroundListener.cs`):

| Member | Description |
|---|---|
| `Id` | Unique identifier (convention: `"mod_id.listener_name"`), used in logs and dependency ordering |
| `OnBeforeExpAward(WingroundContext)` | EXP data is populated but not yet applied to characters; the loot list is still empty |
| `OnAfterLootGenerated(WingroundContext)` | Loot has been rolled from defeated enemies but not yet added to inventory — mutate freely |
| `OnBeforeShow(WingroundContext)` | All processing done; final chance to mutate data before the victory UI is shown |

Hooks fire in that order during `WingroundBehaviour.Show`. The same `WingroundContext` instance is passed through every phase, so mutations made by earlier hooks are visible to later hooks and listeners.

Registration goes through the `WingroundListenerRegistry` singleton:

- **C# mods**: implement `IWingroundListener` with a parameterless constructor — `TurnBattleSceneInitializer` scans mod assemblies and registers implementations automatically (replaces the old core-side fan-out; the battle mod no longer lives in the core)
- Exceptions thrown by individual listeners are logged and swallowed so one broken mod cannot break the victory screen for others

### `WingroundContext`

Mutable context shared by all listeners (`mods/turn_battle_base/src/WingroundContext.cs`):

| Field | Type | Description |
|---|---|---|
| `PlayerExpGained` | `Dictionary<string, int>` | Per-character EXP earned, keyed by character ID |
| `DefeatedEnemies` | `List<Character>` | Enemies defeated this battle; drives loot generation and recruitment rolls |
| `GeneratedLoot` | `List<ItemContainer>` | Loot items — add, remove, or modify entries before they are committed to inventory |
| `BattleData` | `BattleData` | Reference to the battle descriptor shared with the quest system — do not mutate the object itself |

### GDScript variant

A `.gd` script exposing `get_winground_listener_id()` returning a unique string, plus any of the optional hooks `on_winground_before_exp(ctx)` / `on_winground_after_loot(ctx)` / `on_winground_before_show(ctx)`, is detected by `TurnBattleGDScriptHook`, wrapped in a `GDWingroundListener`, and registered automatically. All three hooks are optional — only the methods the script actually implements are called.

The C# context is marshalled into a plain `Dictionary` before each call and synced back afterwards: mutations to `exp_gained` and `generated_loot` propagate to the C# victory pipeline, while `defeated_enemies` are read-only dictionary snapshots whose changes are *not* written back. Dictionaries replace the old RefCounted DTOs because mod-authored C# classes get no Godot source generation and would be invisible to GDScript.

```gdscript
extends RefCounted

func get_winground_listener_id() -> String:
    return "my_mod.double_exp"

func on_winground_before_exp(ctx) -> void:
    for id in ctx["exp_gained"]:
        ctx["exp_gained"][id] *= 2
```

## Battle speed control

`BattleSpeedControl` (`mods/turn_battle_base/src/BattleSpeedControl.cs`) is the x1/x4/x8 toggle bar shown during battle:

| Member | Description |
|---|---|
| `ApplySavedSpeed()` | Restores `Engine.TimeScale` from `GameData.Settings.BattleSpeed` (out-of-range values fall back to x1); called from the widget constructor |
| `ResetToNormal()` | Resets `Engine.TimeScale` to 1.0 *without* saving — the battle scene calls it when the battle ends so menus run at normal speed |

Picking a speed sets `Engine.TimeScale`, which accelerates the entire game loop (not just animations), and persists the choice to `GameData.Settings.BattleSpeed`.

## Animation layer

Visual feedback is decoupled from combat logic so battle cores stay engine-agnostic (`mods/turn_battle_base/src/Adapters/`):

| Type | Role |
|---|---|
| `IBattleAnimation` | Data-only description: `Source`/`Target` characters, `AnimationType` dispatch key, `Speed` multiplier, open-ended `Parameters` dictionary |
| `IBattleAnimationPlayer` | Plays one animation type — `Play(anim, onComplete)` must invoke the callback so the action machine can advance |
| `AnimationRegistry` | Singleton index (`Instance`) keyed by `AnimationType`; `Register()` replaces any existing player for the same key, `Get()` looks up |

Executors never play animations directly — they request them via `IBattleContext.ScheduleAnimation(IBattleAnimation)`, and the playback layer resolves the matching player from the registry.

## Executor routing recap

`ActionExecutorHub` resolves an action ID in two tiers:

```mermaid
flowchart LR
    A["Execute(action, user, target)"] --> B{"Active provider<br/>set?"}
    B -- "yes" --> C["provider.GetExecutor(id)"]
    C -- "miss" --> D["Global registry<br/>RegisterGlobal(executor)"]
    B -- "no" --> D
    D -- "miss" --> E["Result = false"]
    C -- "hit" --> F["executor.Execute()"]
    D -- "hit" --> F
```

- **Global tier**: `ActionExecutorHub.Instance.RegisterGlobal(executor)` — the fallback pool, including auto-registered GDScript executors (a `.gd` file with `get_action_id()` + `execute()`)
- **Provider tier**: the active battle core installs per-core overrides via `SetActiveProvider(IActionExecutorProvider)`
- Lookup order: active provider first, global registry as fallback; no match (or a null action) yields `ChangedAttributes { Result = false }`

## Creating a custom battle system mod

1. Create a new mod with `manifest.json`:
```jsonc
   {
     "name": "MyBattle",                                   // Human-readable mod name
     "description": "Custom battle system",                // Short description
     "sources": ["src/**/*.cs"],                           // Glob patterns for C# source files
     "depends": ["cthangover_game", "turn_battle_base"]  // Battle platform dependency
   }
```

2. Implement `IBattleCore`:
   ```csharp
   public class MyBattleCore : IBattleCore
   {
       public string Id => "MyBattle";
       public IActionExecutorProvider ActionProvider => new MyActionProvider();

       public void Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx)
       {
           // Create enemy panels, character widgets, action buttons
       }

       public void Start()
       {
           // Begin turn loop
       }
   }
   ```

3. Implement `IActionExecutorProvider` to provide your action executors:
   ```csharp
   public class MyActionProvider : IActionExecutorProvider
   {
       public IActionExecutor GetExecutor(string actionId)
       {
           return actionId switch
           {
               "mybattle.attack" => new MyAttackExecutor(),
               "mybattle.defend" => new MyDefendExecutor(),
               _ => null
           };
       }
   }
   ```

4. In scenarios, select your core before initiating battle:
   ```scenario
   action battle.set_core core=MyBattle
   action battle.init scene=town_entry enemies=wolf_1
   switch_scene Battle
   ```

## Battle-related Godot scenes

All battle scenes live in the `turn_battle_base` mod and are resolved through the runtime tscn pipeline by mod-relative path:

| Path | Purpose |
|---|---|
| `scenes/battle/battle.tscn` | Main battle scene container (`BattleSceneContext`) |
| `scenes/battle/winground.tscn` | Victory screen overlay |
| `scenes/battle/deadground.tscn` | Defeat screen overlay |
| `scenes/battle/exp_report_card.tscn` | Experience report card |
| `scenes/battle/loot_report_item.tscn` | Loot grid item |

===== DOC docs/mods/catalog/index.md: Mod Catalog & Network Repository =====
The mod catalog system lets players browse and install mods from online
repositories. A catalog is a JSON file hosted on a web server; the game
fetches it, merges entries from multiple URLs, and compares them against
locally-installed mods.

## Catalog JSON Schema

```jsonc
{
  "catalog": {                                             // Catalog metadata envelope
    "version": 1,                                          // Schema version of this catalog file
    "updated_at": "2026-07-22T12:00:00Z"                   // ISO-8601 timestamp of last server update
  },
  "mods": [                                                // List of mod entries in this catalog
    {
      "id": "live2d_base",                                 // Canonical mod identifier (matches manifest.json id)
      "name": "Live2D Cubism",                             // Fallback display name (used when no locale matches)
      "author": "ct",                                      // Author credit string
      "version": "1.0.0",                                  // Latest release version (semver)
      "description": "Live2D Cubism C# runtime",           // Fallback short description (used when no locale matches)
      "locale": [                                          // Localized name and description entries
        {
          "lang": "en",                                    // Language code (ISO 639-1)
          "name": "Live2D Cubism",                         // Localized display name
          "description": "Live2D Cubism C# runtime: model loading, rendering, physics, animation, effects"
        },
        {
          "lang": "ru",
          "name": "Live2D Cubism",
          "description": "Live2D Cubism C# рантайм: загрузка моделей, отрисовка, физика, анимации, эффекты"
        }
      ],
      "category": "library",                               // Category slug or comma-separated combination (e.g. "library, gameplay"): story, characters, gameplay, visual, audio, ui, locale, library, engine, overhaul, tools, editor, debug, other
      "tags": ["live2d", "ui", "graphics", "2d"],          // Search/filter tags
      "icon_url": "https://git.cthangover.ru/cthangover/mod_live2d/raw/branch/master/mod.png", // Full URL to preview icon (PNG or JPEG)
      "depends": ["cthangover_game>=1"],                              // Versioned dependency strings
      "conflicts_with": [],                                // Mutually exclusive mod IDs
      "downloads": [                                       // Version-specific download entries with mirrors
        {
          "version": "1.0.0",                              // Semver version of this download entry
          "urls": [                                        // Mirror URLs tried in order
            "https://git.cthangover.ru/cthangover/mod_live2d/releases/download/mod/live2d-v1.0.0.zip"
          ],
          "size_bytes": 1831899,                           // File size of the zip for progress display
          "archive_sha256": "075a3f582919b26b39afc1fd66d1e4a91cab0e521d6ca36f746c248e1ee82c9a", // SHA256 of the .zip file (integrity check after download)
          "install_sha256": "db738a14cda17a358d3a0132b97a382978bf9db7b7ea3db750b346b943ba57e6",  // SHA256 of extracted mod content (modification detection)
          "changelog": null                                // Optional changelog for this specific version
        }
      ],
      "status": "beta",                                    // stable, beta, deprecated, or archived
      "min_game_version": null,                            // Minimum compatible game version (semver)
      "max_game_version": null                             // Maximum compatible game version, or null for no upper bound
    }
  ]
}
```

### Root envelope

| Field | Type | Description |
|---|---|---|
| `catalog.version` | int | Schema version of this catalog file |
| `catalog.updated_at` | string | ISO-8601 timestamp of last server update |
| `mods` | array | List of mod entries |

### Per-mod entry

| Field | Type | Description |
|---|---|---|
| `id` | string | Canonical mod identifier (must match the mod's `manifest.json` `id`) |
| `name` | string | Fallback display name (used when no `locale` entry matches the player's language) |
| `author` | string | Author credit |
| `version` | string | Semver version of the latest release (e.g. "1.0.0") |
| `description` | string | Fallback short description (used when no `locale` entry matches the player's language) |
| `locale` | object[] | Localized name/description entries for different languages (see below) |
| `category` | string | Category slug or comma-separated combination of slugs (e.g. `"library, gameplay"`): `story`, `characters`, `gameplay`, `visual`, `audio`, `ui`, `locale`, `library`, `engine`, `overhaul`, `tools`, `editor`, `debug`, `other` |
| `tags` | string[] | Search/filter tags |
| `icon_url` | string | Full URL to preview icon (PNG or JPEG) |
| `depends` | string[] | Versioned dependency strings (e.g. `"cthangover_game>=1"`) |
| `conflicts_with` | string[] | Mutually exclusive mod IDs |
| `downloads` | object[] | Version-specific download entries with mirrors |
| `downloads[].version` | string | Semver version of this download entry |
| `downloads[].urls` | string[] | Mirror URLs for the .zip archive (tried in order) |
| `downloads[].size_bytes` | long | File size of the zip (for progress display) |
| `downloads[].archive_sha256` | string | SHA256 of the .zip file itself (integrity check after download) |
| `downloads[].install_sha256` | string | SHA256 of the extracted mod content (modification detection) |
| `downloads[].changelog` | string | Optional changelog describing what changed in this specific version |
| `status` | string | `stable`, `beta`, `deprecated`, or `archived` |
| `min_game_version` | string | Minimum compatible game version (semver) |
| `max_game_version` | string | Maximum game version, or null |
| `source_urls` | string[] | *Written by the game, not by catalog authors*: filled in during merge with every catalog URL that published the entry, and persisted in `user://catalog_cache.json` |
| `variant_conflict` | bool | *Written by the game*: set when two catalogs publish the same id/version/title with different content hashes — the entries are kept separate instead of merged so the UI can warn about a likely modified build |

### `locale[]` sub-fields

Each entry in the `locale` array has three fields:

| Field | Type | Description |
|---|---|---|
| `lang` | string | Language code (ISO 639-1, e.g. `"en"`, `"ru"`) |
| `name` | string | Localized display name |
| `description` | string | Localized short description |

Resolution order: exact language match → fallback to `"en"` → first entry in the array → top-level `name`/`description`.

## Checksums

Two SHA256 hashes are stored per download entry (per version):

**`archive_sha256`** hash of the .zip archive as it sits on the server.
The game verifies this after downloading to detect transfer corruption.

**`install_sha256`** hash of the mod's extracted content, computed by:
1. Enumerating all files in the mod (recursively)
2. Excluding dot-prefixed files/directories (`.godot/`, `.import/`,
   `.gitignore`) and patterns from `catalog_ignore_patterns` in
   `mod_config.json`
3. Sorting files by path (case-insensitive)
4. For each file: `SHA256(path_bytes + content_bytes)`
5. Final: `SHA256(file_hash_1 + file_hash_2 + ...)`

When the game detects that a locally-installed mod's `install_sha256`
differs from the catalog value, it shows a "Modified locally" warning.
Overwriting such a mod from the network would lose the player's changes.

## Configuration

Catalog URLs are configured in `config/mod_config.json`:

```jsonc
{
  "catalog_urls": [                                        // URLs to fetch catalog.json from (merged in order)
    "https://example.com/mods/catalog.json",
    "https://mirror.example.com/mods/catalog.json"
  ],
  "catalog_ignore_patterns": [".*", "bin", "obj"]          // Glob patterns to exclude from install_sha256 computation
}
```

The **code default** for `catalog_ignore_patterns` is `[".*"]` (`ModConfig.CatalogIgnorePatterns`), which excludes all dot-prefixed entries. The `config/mod_config.json` shipped with the game extends this to `[".*", "bin", "obj"]` to also skip build output directories.

Multiple URLs are merged — when the same mod appears in two catalogs,
the entry with the highest `version` wins for metadata (name, description,
category, etc.), while `downloads` arrays from all sources are merged:
same-version entries have their `urls` lists combined (deduplicated).

The game caches fetched catalogs to `user://catalog_cache.json`.
Open the Network tab in the mods menu (Mods > Network) and press
**Refresh** to clear the cache and re-fetch.

Architecture note: the catalog system provides the *data source* for an HTTP catalog scanner. For details on how a custom scanner turns catalog JSON into `DiscoveredModEntry` items that the kernel can load, see Resources — Scanners.

## Exporting a mod to the catalog

From the mods menu, select a locally-installed mod and click
**Export to Catalog**. This opens a dialog where you fill in the
download URL, category, tags, and changelog, then generates:

- A `.zip` archive of the mod (if it was a folder)
- A `.json` catalog entry file alongside the archive

Categories are pre-checked from the mod's `manifest.json` `category`
field (falling back to `other` when absent); several categories can be
toggled at once.

Both files go into the mods root directory. The JSON snippet can be
copied to clipboard and pasted into the catalog JSON you host online.

Ignore patterns for the SHA256 computation can be customized in
`mod_config.json` via `catalog_ignore_patterns`. The code default `[".*"]`
excludes all dot-prefixed entries; the shipped `config/mod_config.json`
adds `"bin"` and `"obj"`.

## Mod status display

In the Network tab, each remote mod shows one of four statuses:

| Status | Meaning |
|---|---|
| **Installed** | Same version, same content hash |
| **Update available** | Newer version in catalog |
| **Not installed** | No local copy found |
| **Modified locally** | Same version, but `install_sha256` differs player edited the mod |

===== DOC docs/mods/characters/index.md: Characters =====
JSON-defined characters — both player-controlled and enemies — with attributes, actions, skills, and loot tables.

## File format

Every character file is a self-describing envelope. The top-level `Kind` is the
routing discriminator consumed by `EntityResolver`; `Items` carries the
definitions — a file may define one character or many:

```jsonc
{
  // Entity kind — the type discriminator, not a directory convention
  "Kind": "character",
  "Items": [
    { "Id": "wolf_1", "...": "..." }
  ]
}
```

Directories no longer imply a type: a character file is found wherever it lives
in the mod. Player characters and enemies are the same `character` kind —
`CharacterFactory` resolves both through one collection.

## JSON structure

```jsonc
{
  // Unique character identifier — used in scenario conditions and battle init
  "Id":          "unique_id",
  // Locale key resolved from .properties files for the display name
  "Name":        "locale_key",
  // Path to the portrait sprite inside the mod's characters/ group
  // (extension optional)
  "Image":       "path/in/characters/group",
  // AI behaviour strategy: "base" for random actions, "boss" or any custom AI type
  "Behaviour":   "base",

  // Character level (determines scaling)
  "Level":       1,
  // Action points available per turn
  "Point":       1,
  // Maximum hit points (coerced to minimum 1)
  "Health":      2000,
  // Base defence stat (reduces incoming damage)
  "Defence":     0,
  // Base attack stat (physical damage)
  "Attack":      500,
  // Experience points rewarded on defeat
  "Exp":         5,
  // % chance to recruit after defeating this character (0–100)
  "RecruitmentChance": 0,

  // Action IDs available to this character; supports $add, $remove, $set in patches
  "Actions":     ["physics/attack", "physics/defence"],
  // Loot table — items dropped on defeat; patches match by ItemId for per-field merge
  "Loot": [
    {
      // ID of the item to drop (references an item definition)
      "ItemId":      "food/wolf_meat",
      // Minimum number of this item to drop
      "MinCount":    1,
      // Maximum number of this item to drop
      "MaxCount":    1,
      // Drop probability (0–100)
      "Probability": 100
    }
  ],

  // Any other keys — e.g. custom stats — are captured as extension data
  // (see "Extra JSON keys" below). They do NOT become typed attributes.
  "MyMod/Luck":  7
}
```

## Field reference

| Field | Type | Description |
|---|---|---|
| `Id` | string | Unique character ID used in scenario conditions and battle init |
| `Name` | string | Locale key for display name (e.g. `characters/enemies/wolf`) |
| `Image` | string | Path to the portrait sprite **inside the mod's `characters` texture group** — extension optional |
| `Behaviour` | string | AI behaviour class: `"base"` for random actions |
| `Level` | int | Character level |
| `Point` | int | Action points per turn (coerced to minimum 1) |
| `Health` | int | Maximum HP (coerced to minimum 1) |
| `Defence` | int | Base defence stat |
| `Attack` | int | Base attack stat |
| `Exp` | int | XP reward on defeat |
| `RecruitmentChance` | int | % chance to recruit after defeat (0–100) |
| `Actions` | array of string | Action IDs (e.g. `["physics/attack", "physics/defence"]`). Supports `$add`, `$remove`, `$set` in patches. Unknown IDs are silently skipped |
| `Loot` | array of objects | Item drops. Identity key: **`ItemId`** — patches match by this field for per-field merge |
| *(any other key)* | any | Captured as extension data into the character's `Properties` bag — see below |

### Image resolution

`CharacterFactory.LoadImage` resolves the `Image` value via
`TextureUtils.LoadFromModGroup("characters", imagePath)` — the sprite must live
inside the mod's **`characters/`** group (e.g. `characters/enemies/wolf_1.png`
for `"Image": "enemies/wolf_1"`). The leading `characters/` prefix may be
included in the value; it is stripped during normalization, and the file
extension can be omitted.

The separate `avatars/` group belongs to `AvatarFactory` (dialog portraits) and
is **not** consulted for this field — see Avatars for that system.

### Extra JSON keys

Only `Health`, `Defence`, `Attack`, and `Point` are typed battle attributes
(wrapped in `Attribute` — see Attribute model). Every other
JSON key that does not match a DTO field (`Strength`, `Magic`, `Fullness`,
`Depravity`, `Discipline`, or anything custom) lands in
`CharacterInfo.ExtraProperties` via `[JsonExtensionData]` and is imported into
the character's `CharacterAttributes.Properties` string-keyed property bag
(`PropertyData.ImportJsonProperties`).

Be honest with yourself about what this gives you:

- **Numbers** import as int (or float when fractional), **booleans** as bool,
  **strings** as strings; arrays and objects are stored as their raw JSON text.
- **Core code never reads `Properties`.** Keys such as `Strength` or `Fullness`
  are inert data unless your own mod code consumes them. They are *not* typed
  attributes and take no part in battle math, hunger, or recruiting unless you
  implement that logic yourself (e.g. in a scenario action or behaviour class).

## Real examples

### Player character — `cthangover_game/characters/player/marao.json`

```jsonc
{
  // Player character ID — referenced throughout the game
  "Id": "Marao",
  // Locale key for the character's display name
  "Name": "characters/player/marao",
  // Portrait sprite under characters/player/marao.png
  "Image": "characters/player/marao",
  // Starting level
  "Level": 1,
  // Action points per turn
  "Point": 1,
  // Base health pool
  "Health": 16,
  // Base defence
  "Defence": 0,
  // Base attack damage
  "Attack": 5,
  // Available combat actions
  "Actions": ["physics/attack", "physics/defence"]
}
```

### Enemy — `cthangover_game/characters/enemies/wolf_1.json`

```jsonc
{
  // Unique enemy ID for this wolf variant
  "Id": "wolf_1",
  // Locale key for the display name
  "Name": "characters/enemies/wolf",
  // Portrait sprite under characters/enemies/wolf_1.png
  "Image": "characters/enemies/wolf_1",
  // AI behaviour: "base" picks random actions
  "Behaviour": "base",
  // Enemy level
  "Level": 1,
  // Action points per turn
  "Point": 1,
  // Hit points (low — this is a weak enemy)
  "Health": 15,
  // Base defence
  "Defence": 0,
  // Base attack damage
  "Attack": 4,
  // XP granted on defeat
  "Exp": 5,
  // Cannot be recruited
  "RecruitmentChance": 0,
  // Combat actions
  "Actions": ["physics/attack", "physics/defence"],
  // Loot table — drops wolf meat and branches
  "Loot": [
    { "ItemId": "food/wolf_meat", "MinCount": 1, "MaxCount": 1, "Probability": 100 },
    { "ItemId": "resource/branches", "MinCount": 1, "MaxCount": 1, "Probability": 100 }
  ]
}
```

### Boss enemy — `cthangover_game/characters/enemies/werewolf_girl_1.json`

```jsonc
{
  // Unique boss ID
  "Id": "werewolf_girl_1",
  // Locale key for display name
  "Name": "characters/enemies/werewolf_girl",
  // Boss level
  "Level": 1,
  // Hit points (higher than regular enemies)
  "Health": 25,
  // Attack damage (stronger than regular enemies)
  "Attack": 8,
  // XP reward (significantly higher than regular enemies)
  "Exp": 50,
  // 50% chance to recruit after defeat — see [Recruiting](../recruiting/)
  "RecruitmentChance": 50,
  // Combat actions
  "Actions": ["physics/attack", "physics/defence"],
  // Loot table — drops 2 wolf meat at 100% probability
  "Loot": [
    { "ItemId": "food/wolf_meat", "MinCount": 2, "MaxCount": 2, "Probability": 100 }
  ]
}
```

## Battle actions (ActionCharacter)

Every entry in a character's `Actions` array references an **ActionCharacter**
definition (`Core/Characters/ActionCharacter.cs`). Actions define what a
character can do in battle — attack, defend, stun, heal — together with their
targeting category and free-form parameters.

Actions are entities in their own right, collected by `ActionCharacterFactory`
through the shared entity resolver: the file declares `"Kind": "action"` and
can live anywhere in the mod:

```jsonc
{
  // Action files are routed by their own kind, independent of characters
  "Kind": "action",
  "Items": [{
    // Unique action ID — referenced by characters' "Actions" arrays
    "Id": "mymod/fireball",
    // Locale key for the name shown in the battle action menu
    "Name": "characters/action/fireball/name",
    // Locale key for the tooltip text
    "Description": "characters/action/fireball/description",
    // Targeting category: "ToSelf", "ToAlias" (allies), or "ToEnemy";
    // "Passive" and "Active" are non-targeting kinds (see below)
    "Type": "ToEnemy",
    // Icon sprite path, resolved in the characters texture group
    "Image": "characters/actions/fireball",
    // Flat list of "Key=Value" strings parsed into the Properties bag;
    // everything before the first '=' is the key, the rest is the value
    "Values": [
      "RequiredPoint=2",
      "Attack=3.5",
      "AttackType=Fire"
    ]
  }]
}
```

| Field | Description |
|---|---|
| `Id` | Unique action ID, used as the factory lookup key |
| `Name` | Locale key for the display name in the battle action menu |
| `Description` | Locale key for the tooltip |
| `Type` | `ActionCharacterType` — `ToSelf` (caster), `ToAlias` (allies), `ToEnemy` (opponents), plus the non-targeting `Passive` and `Active` kinds (see below) |
| `Image` | Icon sprite resolved via `TextureUtils.LoadFromModGroup("characters", ...)` |
| `Values` | List of `"Key=Value"` strings populating the `Properties` bag |

Besides the three in-battle targeting categories, `ActionCharacterType` declares
two kinds handled by the character panel instead of battle executors:

- **`Passive`** — never appears in an action menu. C# code checks flags via
  `CharacterPassiveExtension.HasPassiveEffect(character, key)`, which is true when
  any equipped passive sets that key to `true` in its `Values` (e.g.
  `"IgnoreArmor=true"`; key names are mod-chosen — there is no registry).
- **`Active`** — a one-shot ability used from the character panel outside battle.
  Cooldown state lives in the action's own `Properties` bag: `ActiveCooldown`
  (seconds, declared in your `Values`) and `LastUsed` (timestamp recorded by
  `MarkUsed`). The helpers `IsReadyToUse(action, now)` and
  `GetRemainingCooldown(action, now)` in `ActionCharacterActiveExtension`
  implement the cooldown check.

There are no reserved parameter keys at the definition level — core executors
read whatever keys they care about (the stock physics actions use
`RequiredPoint`, `Attack`, `AttackType`, `Defence`, `Turn`), and custom C#
executors (`IActionExecutor`) can read their own via the `GetStr` / `GetInt` /
`GetFloat` / `GetBool` helpers on `ActionCharacter`.

The cthangover_game mod keeps its actions in `characters/actions/*.json`,
each file a standalone `Kind: "action"` envelope — there is no index file:

```jsonc
{
  "Kind": "action",
  "Items": [ { "Id": "physics/attack", "...": "..." } ]
}
```

`ActionCharacterFactory.Get(id)` returns a **copy** of the cached definition on
every call — action cards carry mutable battle state (cooldowns, modifiers), so
character instances never share one instance.

## Attribute model

The stat block (`CharacterAttributes`, `Core/Characters/CharacterAttributes.cs`)
has exactly four attributes, each wrapped in an `Attribute` object:

| Attribute | Meaning |
|---|---|
| `Health` | Hit points (`Value` = current, `BaseValue` = max) |
| `Defence` | Reduces incoming damage |
| `Attack` | Base damage dealt |
| `Point` | Action points consumed by abilities |

`Attribute` (`Core/Characters/Attribute.cs`) mechanics worth knowing when
writing battle code:

- `Value` — current value; setting it fires `OnChange`.
- `BaseValue` — unmodified maximum.
- `Percent` — `Value / BaseValue` ratio for health bars (0 when `BaseValue` is 0).
- `Init(int)` — sets `Value` and `BaseValue` to the same starting value.
- `Copy()` — clones `Value`/`BaseValue`; **`OnChange` subscribers are not copied**.
- `OnChange` — a `ChangeAttribute` delegate (`void(float value, float baseValue)`).
  It fires on **every** `Value` setter call, even when the value didn't change —
  treat it as a notification mechanism, not a diff detector (a fizzle-heal at
  full health still triggers UI feedback).

Battle actions report their outcome as a `ChangedAttributes` struct
(`Source` / `Target` — `CharacterSet` snapshots of both sides after the exchange,
plus a `bool Result` success flag). This is what `IActionExecutor.Execute(action, user, target)`
returns; the executor hub matches executors to actions by `ActionId`.

## Factory behavior

- **Minimum coercion** — `CharacterFactory.CreateCharacter` coerces `Health` and
  `Point` to a minimum of 1 (`Health > 0 ? Health : 1`, same for `Point`);
  `Defence` and `Attack` are taken as-is, including 0.
- **Copies on read** — `CharacterFactory.Get(id)` returns a deep copy per call
  (`Character.Copy()`): `Attributes` are cloned, so battle mutations never leak
  into templates. The clone carries no battle-only state — status effect queues
  are not fields of `Character`; battle code obtains them through
  `StatusEffectService.Of(character)` (see Status Effects).
- **Unknown action IDs** are dropped silently when resolving the `Actions` list.

## Runtime members worth knowing

| Member | Description |
|---|---|
| `Character.Frame` | Small portrait `Texture2D` for list views (party roster, battle HUD). Present on the model but **not populated from JSON** by the factory |
| `Character.Exp` | Current experience points, loaded from the template and carried into party data on recruitment |
| `StatusEffectService.Of(character)` | Active status-effect queue for a combatant. Lives in `mods/turn_battle_base` as a `ConditionalWeakTable` side table — not on `Character` — and dies with its owner; see Status Effects |
| `Character.Copy()` | Deep clone used everywhere the engine hands out characters (see above) |
| `CharacterData.Create(characterType)` | Builds runtime `CharacterInfoData` from the factory template. For an **unknown ID** it does not throw — it returns a fallback entry with default attributes at level 0 |

## Character index files

There are none. Every character file declares its kind itself, so the old
`characters.json` / `enemies.json` index files with `${...}` includes are gone:
adding a character means dropping a `Kind: "character"` file anywhere in the
mod, and later mods override earlier definitions by `Id`.

## Adding a new character from scratch

1. Create a JSON file anywhere in your mod with the `{ "Kind": "character", "Items": [ ... ] }` envelope and all required fields
2. Put the portrait sprite inside your mod's `characters/` **texture** group so the `Image` path resolves (extension optional); dialog portraits, if needed, go separately under `avatars/{id}/` — see Avatars
3. Register locale keys for the name in `locale/en.properties`:
```
characters/{type}/{id} = My Character Name
```
4. Reference in battle init: `action battle.init enemies=my_char_id`

===== DOC docs/mods/collections/index.md: Mod Collections =====
A **collection** is a curated set of mods that together implement a game
mechanic — a card novel, a developer toolset, a battle system with its UI.
Unlike a category label on a single mod, a collection describes a
*composition*: the same mod can be the required backbone of one collection
and an optional layer of another, so mechanics belong to the collection file,
not to the mod manifest.

Collections are the game-construction unit the catalog is organized by: the
player is shown a scheme split into mechanics and blocks instead of a flat mod
list, and can install everything the game needs with one action. The
collection file itself never stores the dependency arrows — they are derived
from the mods' own `depends`, so the scheme cannot go stale when a mod author
changes dependencies.

## Where collections live

A collection is a **package of its own, not a file inside a mod**. A game is an
assembly of mods by many authors — no single mod owns it — so collections ship
next to the mods: anyone can publish a game, a toolset or a block without
touching (or forking) the mods it uses.

A package is a folder or a `.zip` named after the collection id:

```
collections/my_novel/
  collection.json        ← the document (schema below)
  cover.png              ← "cover": "cover.png" — shown in the game picker
  locale/en.properties   ← texts of the collection ("@key" markers), optional
  locale/ru.properties
```

| Source | Location | Precedence |
|---|---|---|
| Shipped with the game | `collections/<id>/` in the game directory (`res://collections/`) | overrides catalog |
| The player's | `user://collections/<id>/` — edited, duplicated and downloaded collections | overrides everything |
| Published in a catalog | `"collections"` array of a catalog JSON (document only: no cover, no locale files) | lowest |

`ModCollectionStore.LoadAll()` merges the three channels by collection `id`
(case-insensitive): catalog entries are inserted first, shipped packages
replace them, and the player's packages replace both. Packages are read through
`IModFileProvider`, so folders and zips work identically. The package's
`locale/` files are merged into the game's translations at boot, before any
mod, so the game picker shows localized names before the game's mods are
loaded. **Mods do not ship collections**: a `collections/` folder inside a mod
is not read.

## Collection JSON schema

```jsonc
{
  "id": "my_novel",                                        // Required. Canonical id, unique across all sources
  "kind": "Game",                                          // Game | Component | Toolset — see "Collection kinds"
  "name": "My Novel",                                      // Display name (falls back to id; see Localization)
  "author": "ct",                                          // Author credit
  "version": "1.0.0",                                      // Semver; picks the newest of same-id catalog collections
  "description": "A visual novel with card battles.",      // Shown on the gallery card and in the details panel
  "tags": ["novel", "cards"],                              // Free-form search tags
  "provides": ["battle_system"],                           // Capability ids this collection implements
  "requires": ["character_system"],                        // Capability ids needed from sibling blocks
  "includes": [                                            // Child collections this one is assembled from
    {
      "ref": "rpg_2d>=1",                                  // Versioned collection reference (same syntax as depends)
      "role": "systems",                                   // Slot role; two required blocks cannot claim one slot
      "optional": false,                                   // True when the block may be excluded by the player
      "selected": null,                                    // Player's choice for an optional block (null = on)
      "label": "2D Foundation",                            // Per-game name override (falls back to child name)
      "note": "Platformer core with map and inventory."    // Per-game description override
    }
  ],
  "mechanics": [                                           // Ordered mechanics — drives the scheme layout
    {
      "id": "vn_core",                                     // Stable id, unique within the collection
      "name": "Novel Core",                                // Frame title in the scheme
      "description": "Scenes, scenarios, dialogue.",       // Tooltip
      "role": "story",                                     // Slot role the mechanic fills in the scheme editor
      "mods": ["cthangover_game>=1"],                    // Versioned mod references (same syntax as manifest depends)
      "optional": false,                                   // Marks the mechanic as skippable
      "selected": null,                                    // Player's choice for an optional mechanic (null = on)
      "locale": [                                          // Per-mechanic localized strings, same shape as below
        { "lang": "ru", "name": "Ядро новеллы", "description": "Сцены, сценарии, диалоги." }
      ]
    }
  ],
  "locale": [                                              // Localized name/description of the whole collection
    { "lang": "en", "name": "Card Novel", "description": "A visual novel with card battles." },
    { "lang": "ru", "name": "Карточная новелла", "description": "Визуальная новелла с карточными боями." }
  ]
}
```

### Field reference

| Field | Required | Description |
|---|---|---|
| `id` | Yes | Canonical collection id. Entries without an id are ignored |
| `kind` | No | `Game`, `Component` or `Toolset`. Missing values deserialize to `Game` so documents written before kinds existed keep working |
| `name` | No | Display name; the id is used when absent |
| `author` | No | Author credit |
| `description` | No | Short description shown in the UI |
| `version` | No | Semver. When several catalogs publish the same id, the highest version wins |
| `tags` | No | Free-form search tags (`"systems"`, `"cards"`, `"2d"`, ...) |
| `provides` | No | Capability ids this collection implements. The scheme editor uses them to suggest compatible blocks; resolution itself does not enforce them |
| `requires` | No | Capability ids needed from sibling blocks. The resolver reports a problem when no resolved block provides them |
| `includes` | No | Child collections this one is assembled from — see below |
| `mechanics` | No | Ordered list of mechanics; the order drives the top-to-bottom scheme layout |
| `mechanics[].id` | No | Unique id used to group nodes; empty ids get a stable `mechanic_<index>` fallback |
| `mechanics[].name` | No | Frame header; the id is used when absent |
| `mechanics[].description` | No | Frame tooltip |
| `mechanics[].role` | No | Slot role (`"characters"`, `"story"`, `"battle"`, ...). The scheme editor groups the mechanic's mods under this label and role conflicts are detected against it |
| `mechanics[].mods` | No | Versioned mod strings (see below) |
| `mechanics[].optional` | No | Marks the mechanic as skippable (`false` by default). The install plan still includes it until the player turns it off |
| `mechanics[].selected` | No | Player's choice for an optional mechanic. `null`/omitted means on — old documents behave as before |
| `mechanics[].locale` | No | Localized name/description entries, same shape as the collection-level `locale` |
| `includes[].ref` | Yes | Versioned collection reference, e.g. `"rpg_2d>=1"` |
| `includes[].role` | No | Slot role inside the parent; two *required* includes claiming one role is a problem |
| `includes[].optional` | No | True when the block may be excluded without breaking the parent |
| `includes[].selected` | No | Player's choice; `null`/omitted means the block is on |
| `includes[].label` / `note` | No | Per-game name/description overrides; null falls back to the child collection's own fields |
| `locale` | No | Localized `name`/`description` entries — see Localization |
| `source_urls` | — | Written by the game only: catalog refreshes tag each collection with the URLs that published it. Local files should not set it |

A collection with neither `mechanics` nor `includes` shows an empty scheme.

### Collection kinds

`kind` is metadata — resolution does not depend on it — but it drives the
games gallery: only `Game` collections are offered as playable assemblies,
while `Component` and `Toolset` blocks are building material picked in the
scheme editor.

| Kind | Meaning |
|---|---|
| `Game` | Finished composition the player can assemble and play (default) |
| `Component` | Reusable block: a mechanic, a system layer, a content group |
| `Toolset` | Developer toolset (editors, validators, the mod manager) |

### Includes — collections made of collections

`includes[]` is the recursive edge of the collection pyramid: a parent game
declares which blocks compose it, each block can itself include others, and
the same block may be included by several parents with different roles.
`ref` uses the same versioned syntax as `manifest.json` dependencies and is
looked up in the merged collection store (user > built-in > catalog).

`label` and `note` are scheme-editor overlays: the player may rename a block
inside one game without touching the shared collection document, so a
component can be called "2D RPG" in the catalog and "Основа" in a specific
game.

### Versioned mod strings

Entries of `mechanics[].mods` and `includes[].ref` use the same format as
`manifest.json` `depends`: `modId[op][version][?]`, where `op` is one of
`>=`, `<=`, `>`, `<`, `=`, `^` or omitted (any version). Examples:

```jsonc
"mods": [
  "cthangover_game",       // any version
  "turn_battle_base>=1",     // at least 1.x
  "card_battle=1.2.0"        // exactly this version
]
```

The pin is used in two places: a locally installed mod that does not satisfy it
is shown as a **version mismatch**, and when installing from a catalog the
newest download satisfying the pin is chosen. The optional suffix `?` is parsed
but ignored — optionality of a whole mechanic or block is expressed with the
`optional` flag.

## Localization

Collection text is localized with two mechanisms; they can be combined.

### 1. The `locale[]` array

Entries carry `lang`, `name` and `description` — the same shape as catalog
mod entries. Mechanics carry their own `locale[]` with the same fields. The
best match for the player's language is picked as:

```
exact lang  ->  flat name/description fields  ->  "en"  ->  first entry with text  ->  id
```

so a shipped collection can present itself in several languages without
duplicating the document.

### 2. The `@key` marker

Any text field that starts with `@` is a **locale key**, not literal text:

```jsonc
"name": "@collections/my_novel/name"
```

The text after `@` is resolved through `TranslationServer`, which merges the
`locale/*.properties` files of **every loaded mod** (keys use `/` as the
namespace separator). If no translation is registered for the key, the raw
text after `@` is displayed — an untranslated marker degrades to a readable
id-like string instead of an error.

Mechanic names get a conventional key layout:

```
collections/<collection_id>/mechanic/<mechanic_id>/name
collections/<collection_id>/mechanic/<mechanic_id>/description
```

Put the keys into the package's own `locale/{lang}.properties` — the
collection carries its texts with it:

```properties
# en.properties
collections/my_novel/name = My Novel
collections/my_novel/description = A visual novel with an optional card-battle layer.
collections/my_novel/mechanic/vn_core/name = Novel Core
collections/my_novel/mechanic/cards/name = Card Battle

# ru.properties — same keys, Russian values
collections/my_novel/name = Моя новелла
```

Because translations are merged from all loaded mods, a separate language-pack
mod can localize collections it does not own.

### Priority

```
locale[] of the exact lang  >  "@key" / flat field (translated if the key exists, raw otherwise)  >  fallback
```

The final fallback is the collection id (names) or an empty string
(descriptions).

## How the scheme is built

`ModCollectionResolver` turns a collection into a `ModCollectionGraph`:

1. `includes[]` are expanded first, recursively, from the merged collection
   store. Each included block becomes a scheme **group** with the child's mods
   flattened inside it; nested blocks collapse into the nearest ancestor, so
   the editor always draws a two-level pyramid. The root's own mechanics
   become leaf groups after the includes.
2. Every mod named by a mechanic becomes a node in that group; if several
   mechanics list the same mod, the first one owns it.
3. For each node, the mod's `depends` list (from the local registry) adds an
   edge **dependency → dependent**, and unknown dependencies are pulled into
   the scheme as external nodes shown in the *Dependencies* frame — including
   transitive dependencies, recursively.
4. Each node gets a state from the live sources:

| State | Node color | Meaning | Plan action |
|---|---|---|---|
| `Installed` | green | Installed, enabled, satisfies the version pin | none |
| `Disabled` | amber | Installed but listed in `disabled_mods.json` | re-enabled on apply |
| `InCatalog` | blue | Not installed, downloadable from a catalog | installed on apply |
| `VersionMismatch` | amber | Installed, but the version fails the collection's pin | reported as a warning |
| `Missing` | red | Unknown to the registry and every catalog | reported; blocks apply |

Inconsistencies found while expanding the pyramid are reported as **problems**
instead of failing resolution:

| Problem | Meaning |
|---|---|
| Missing block | an `includes[]` reference points at a collection nobody provides |
| Cycle | the include chain references a collection already on the active stack |
| Role conflict | two *required* includes claim the same role slot (optional blocks never conflict) |
| Unmet requirement | a resolved collection `requires` a capability no block `provides` |

The scheme is resolved fresh every time the collections screen opens or a
catalog refresh finishes, so it always reflects the current registry, disabled
store and catalog cache.

## The Mods & Collections hub

The mod manager contributes a single main-menu button, **Mods & Collections**
(with a counter of problems requiring attention), which opens one screen with
two tabs — **Mods** (the default) and **Collections**. The tab choice is
remembered, and the two screens stay cross-linked: a mod card lists the
collections that use it and links straight to their schemes, and scheme
problems link back to the mod catalog search.

### Collections tab — the games gallery

The first level of the Collections tab is a gallery of **games**: collections
with `"kind": "Game"`. Every card shows the cover, author/version/origin, the
mod count, tags and a status:

| Status | Meaning | Primary action |
|---|---|---|
| Ready (full progress bar) | every mod installed, enabled, pins satisfied | **Play** |
| Missing N mods | some mods are neither installed nor downloadable | **Show problems** |
| K problems | scheme errors (missing block, cycle, role conflict, unmet requirement) | **Show problems** |
| Draft | a partially assembled player collection, possibly with unsaved edits | **Continue building** |

Secondary action on every card is **Edit scheme**. Tiles **+ Assemble your own
game** create a new player collection, stored as a package in `user://collections/<id>/`.

How a player assembles a game: pick a game in the gallery → **Install** — the
build plan downloads the missing mods and re-enables disabled ones → the game
shows the familiar "changes take effect after a restart" banner. Mods
installed this way are remembered for the rest of the session
(`sessionInstalled`), so the scheme updates immediately even though the
registry only learns about them after a restart.

Applying is blocked while any required mod is `Missing` from the catalog or
while scheme errors are present — enabling a mod whose dependency cannot be
loaded would only make the registry drop it at the next boot. Version
mismatches are shown as warnings but do not block apply.

### Scheme editor

**Edit scheme** opens the second level: a canvas with the game's contours.

- **Canvas** — one draggable/resizable group per mechanic or included block,
  with mod chips inside (state-colored dots); external dependencies render as
  dashed chips. Groups move by their header, mods move inside, the empty
  canvas pans with LMB. A toolbar offers **+ Mechanic** and **+ Block from
  catalog**; a toggle shows/hides dependency links.
- **Inspector** (right side) — tabs: *Inspector* (block metadata,
  provides/requires, actions: add mod, replace block, turn off, remove),
  *Check* (the problem list with "show" navigation to the offending group),
  *Plan* (install/enable counts, missing and mismatched mods).
- **Build bar** (bottom, always visible) — assembly name, **Install N**,
  **Enable N**, **Problems K**, a hint message, a shortcut to the mod manager
  and the primary **Apply collection** button (disabled while errors are
  present).
- **Header** — Save (writes the package `user://collections/<id>/`), Reset and a raw
  **JSON** view, plus the unsaved-edits marker.

## Ship your own collection

1. Create a folder named after your collection id: `collections/<id>/` for a
   game shipped with the build, or `user://collections/<id>/` (zip it to share).
2. Put the document into `collection.json`; the `id` inside must match the
   folder name and be unique across all sources.
3. Set `"kind": "Game"` for a playable assembly, `"Component"` for a
   reusable block, `"Toolset"` for developer tools.
4. Declare `mechanics` (or `includes` of other collections) with versioned
   mod references.
5. Localize: add a `locale[]` array and/or `@key` markers, then put the keys
   into the package's `locale/{lang}.properties`; add a `cover.png`.
6. Ship the package. The store merges it automatically and your
   shipped copy overrides an online catalog entry with the same id; the player
   can override both locally.

A complete example — a game that names its texts through `@key` markers and also
carries `locale[]` for the game picker:

```jsonc
{
  "id": "my_novel",
  "kind": "Game",
  "name": "@collections/my_novel/name",              // resolved from locale/*.properties
  "description": "@collections/my_novel/description",
  "author": "you",
  "version": "1.0.0",
  "entry": { "scene": "my_town" },
  "tags": ["novel", "cards"],
  "locale": [
    { "lang": "en", "name": "My Novel", "description": "A visual novel with an optional card-battle layer." },
    { "lang": "ru", "name": "Моя новелла", "description": "Визуальная новелла с необязательным слоем карточных боёв." }
  ],
  "mechanics": [
    {
      "id": "story",
      "name": "@collections/my_novel/mechanic/story/name",
      "description": "@collections/my_novel/mechanic/story/description",
      "role": "story",
      "mods": ["my_novel_story>=1", "shell2d_base>=1", "dialog_base>=1"]
    },
    {
      "id": "cards",
      "name": "@collections/my_novel/mechanic/cards/name",
      "description": "@collections/my_novel/mechanic/cards/description",
      "role": "battle",
      "optional": true,
      "mods": ["turn_battle_base>=1", "card_battle>=1"]
    }
  ]
}
```

## Catalog distribution

A catalog may publish collections in its root envelope:

```jsonc
{
  "catalog": { "version": 1, "updated_at": "2026-09-28T00:00:00Z" },
  "mods": [ /* ... */ ],
  "collections": [
    {
      "id": "my_novel",
      "kind": "Game",
      "name": "My Novel",
      "version": "1.0.0",
      "mechanics": [
        { "id": "story", "name": "Story", "mods": ["my_novel_story>=1"] }
      ]
    }
  ]
}
```

Collections from all configured catalog URLs are merged by `id` — the highest
`version` wins while `source_urls` accumulates every publisher — and cached
together with the mod entries in `user://catalog_cache.json`. The
`collections` key is optional, so catalogs written before collections existed
keep working.

## Core API

Collections are implemented in the kernel (`Core/Mods/Collections/`), so
launcher code, tools and other mods can use them without the UI:

| Type | Purpose |
|---|---|
| `ModCollection`, `ModCollectionMechanic`, `ModCollectionInclude` | JSON DTOs; `GetLocalizedName(lang)` / `GetLocalizedDescription(lang)` implement the `locale[]` lookup |
| `ModCollectionStore` | `LoadAll()`, `LoadUser()`, `SaveUser()`, `LoadFromProvider(provider)` |
| `ModCollectionResolver.Resolve(collection, collections, localMods, disabledIds, catalogEntries, sessionInstalled = null, language = null, translator = null)` | Builds `ModCollectionGraph` (nodes, edges, groups, problems, states). `translator` (e.g. `CollectionText.Resolve`) resolves `@key` display text; null keeps raw keys |
| `ModCollectionGraph` | `Mechanics`, `Groups` (two-level pyramid), `Problems`, `Nodes`, `Edges`, `InstalledCount`/`TotalCount` |
| `ModCollectionPlan.Build(graph, catalogEntries)` | `Install` items (id, version, source URL), `Enable`, `Missing`, `Mismatches` |
| `ModCollectionNodeState` | `Installed`, `Disabled`, `InCatalog`, `Missing`, `VersionMismatch` |
| `ModCollectionProblem(Kind)` | `MissingCollection`, `Cycle`, `RoleConflict`, `RequirementMissing` |

Example — resolve a collection and inspect its plan:

```csharp
var collections = ModCollectionStore.LoadAll();
var byId = collections.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
var collection = collections[0];
var catalog = CatalogService.CachedEntries ?? new List<CatalogExportEntry>();

var graph = ModCollectionResolver.Resolve(
    collection,
    byId,
    ModRegistry.Instance.AllDiscoveredMods,
    DisabledModsStore.Load(),
    catalog,
    language: "ru");

var plan = ModCollectionPlan.Build(graph, catalog);
foreach (var item in plan.Install)
    GameLogger.Log("MY_MOD", $"install {item.ModId} v{item.Version} from {item.SourceUrl}");
```

Writes (downloading, enabling) stay outside the plan: the UI passes
`plan.Install` to `ModDownloader.InstallAsync` and removes `plan.Enable` from
`DisabledModsStore`.

## Start your own game

A game is three layers, and only the first one is yours to write:

1. **Your game's mod** — `<game>_game` (for example `lighthouse_game`): your
   scenes, scenarios and texts. Copy `mods/my_novel_game`, rename the folder,
   `id`, scene and scenario names (prefix them with your game) and locale keys.
   Its scenario lines are written in English (`text "…" key=…`); other
   languages, Russian included, live only in `locale/scenarios_<lang>.properties`.
2. **Packs and mechanics you reuse** — `town_pack` for backgrounds and sound,
   `cooking` or `card_battle` for gameplay. Depend on them instead of copying
   their files: a fix in the pack reaches every game. To change a pack's
   scenario or background for your game only, ship the same name and depend on
   the pack — your version wins (see load order).
3. **The engine** — `shell2d_base`, `dialog_base`, `audio_base`, …

Then copy `collections/novel_template/` to `collections/<your game>/`, set the
`id`, `entry.scene`, the cover and the mod lists, and the game appears in the
picker.

## Real examples

- `collections/novel_template/` — the smallest playable
  game: the game's own content (`my_novel_game`: scenes, scenarios, text),
  reusable assets (`town_pack`: town, home and bar backgrounds, bar ambience —
  the main game uses the same pack) and the novel engine (`shell2d_base`,
  `dialog_base`, `audio_base`). See "Start your own game" below.
- `collections/cthangover/` — the main game: story,
  novel engine, characters, quests, items, battles with an optional second
  battle core, Live2D and tools, each as its own mechanic.
- `collections/novel_editor/` — a "game" whose entry scene
  is the editor hub instead of a story scene.

===== DOC docs/mods/configs/game_config.md: Game Config — game_config.json =====
Default game configuration loaded from `config/game_config.json` at startup. Defines audio, display, language, and logging settings.

## File location

```filestree
config/
└── game_config.json
```

## Full schema

```jsonc
{
  // Audio defaults
  "audio": {
    "sounds_enabled": true,       // Sound effects master toggle
    "musics_enabled": true,       // Music master toggle
    "ambient_enabled": true,      // Ambient sounds master toggle
    "sounds_volume": 100,         // SFX volume (0–100)
    "musics_volume": 15,          // Music volume (0–100)
    "ambient_volume": 100         // Ambient volume (0–100)
  },
  // Locale code (UI language)
  "language": "en-en",
  // Display settings
  "display": {
    "window_mode": 3,             // 0=Windowed, 1=Min, 2=Max, 3=Fullscreen, 4=Exclusive
    "resolution_width": 1920,     // Width in pixels
    "resolution_height": 1080,    // Height in pixels
    "current_screen": 0,          // Monitor index (0-based)
    "vsync_enabled": false,       // Vertical sync
    "scale": 1                    // UI window scale factor
  },
  // Logging configuration
  "logging": {
    "enabled": true,              // Master logging on/off
    "console_enabled": true,      // Write logs to console
    "minimum_level": "Debug",     // Min level: Debug, Message, Warning, Error
    "enabled_categories": ["SAVE", "MODS", "..."]
  }
}
```

## `audio` section

| Field | Type | Description |
|---|---|---|
| `sounds_enabled` | bool | Master toggle for sound effects |
| `musics_enabled` | bool | Master toggle for music |
| `ambient_enabled` | bool | Master toggle for ambient sounds |
| `sounds_volume` | int | Sound effects volume (0–100) |
| `musics_volume` | int | Music volume (0–100) |
| `ambient_volume` | int | Ambient volume (0–100) |

## `display` section

| Field | Type | Values | Description |
|---|---|---|---|
| `window_mode` | int | 0=Windowed, 1=Min, 2=Max, 3=Fullscreen, 4=Exclusive | Window display mode (Godot `DisplayServer.WindowMode`) |
| `resolution_width` | int | pixels | Target screen width |
| `resolution_height` | int | pixels | Target screen height |
| `current_screen` | int | 0-indexed | Monitor/display index |
| `vsync_enabled` | bool | true/false | Vertical sync |
| `scale` | int | multiplier | UI/window scaling factor |

## `language` section

| Field | Type | Description |
|---|---|---|
| `language` | string | Locale code (e.g. `"en-en"`, `"ru-ru"`) |

## `logging` section

| Field | Type | Description |
|---|---|---|
| `enabled` | bool | Master logging toggle |
| `console_enabled` | bool | Write logs to console output |
| `minimum_level` | string | Minimum log level: `"Debug"`, `"Message"`, `"Warning"`, `"Error"` |
| `enabled_categories` | string[] | Active log channels (see below) |

### Available log categories

Categories are plain strings passed to `GameLogger.Log(category, ...)` — **no `LOG_` prefix**.

| Category | Covers |
|---|---|
| `AUDIO` | Audio playback |
| `BATTLE` | Battle engine events |
| `BATTLE_UI` | Battle interface events |
| `CATALOG` | Remote mod catalog refresh/cache |
| `CATALOG_EXPORT` | Catalog export dialog |
| `CACHE` | Mod cache (shader extraction) |
| `COND` | Condition evaluation |
| `CONFIG` | Configuration loading |
| `CONVERTER` | Scenario converter |
| `DOWNLOADER` | Mod downloads |
| `DUPLICATE` | Duplicate singleton detection warnings |
| `EVENT_BUS` | Mod event bus subscriptions |
| `FACTORY` | Factory loading |
| `GAME` | Game lifecycle |
| `ICON_CACHE` | Catalog icon downloads/cache |
| `GDS_MOD` | GDScript mod loading |
| `INTERACTIVE` | Interactive objects/layers |
| `LANG` | Localization loading |
| `MOD_COLLECTIONS` | Collection store/resolver/plan |
| `MOD_HUB` | Mods & Collections hub screens |
| `MOD_MANAGER` | Mod manager screen actions |
| `MODS` | General mod loading |
| `MODS_ASSEMBLY` | Mod assembly loading |
| `MODS_COMPILE` | Roslyn C# compilation |
| `MODS_EXPORT` | Mod export |
| `MODS_INIT` | Mod initialization |
| `MODS_NATIVE` | Native library handling in mods |
| `MODS_DISCOVERY` | Scanner/loader registry init |
| `MODS_REGISTRY` | Mod registry operations |
| `MODS_SAVE` | Mod save dispatch |
| `MODS_SETTINGS` | Mod settings persistence |
| `MODS_ZIP` | Zip provider file access |
| `QUEST` | Quest system |
| `SAVE` | Save/load operations |
| `SCENE` | Scene transitions |
| `SCENE_CTX` | Scene context nodes |
| `SHADER` | Shader operations |
| `TEST` | Test runner (SceneSwitcher) |
| `TSCN` | `.tscn` scene resolution/parsing |
| `TSCN_TEST` | `.tscn` test driver |
| `UNINSTALLER` | Mod uninstall/cleanup |
| `UI` | UI events |

> **Warning**: the `enabled_categories` whitelist filter is currently **commented out** inside `GameLogger.Log` — category filtering is inert, and all categories are logged regardless of this setting. Note also that the `config/game_config.json` shipped with the game still lists legacy `LOG_`-prefixed strings (e.g. `"LOG_MODS"`); since categories are plain unprefixed strings, those entries never match a real category.

### Log output files

Logs are written to `user://logs/cthangover.log`; entries with level `Error` are also duplicated to `user://logs/cthangover_errors.log`. A `--log-file=<path>` command-line argument overrides the log file location.

## Runtime settings persistence

User-adjusted settings are persisted to `user://settings.json` as indented JSON (`SettingsData.ConfigPath`), separate from this config file. The `game_config.json` only provides defaults — changes made in the settings menu are saved per-user. Note that this is JSON, not an INI `.cfg` file.

===== DOC docs/mods/configs/index.md: Configs — manifest.json =====
Every mod needs a manifest and optionally a properties file. These define the mod's identity, dependencies, C# source compilation rules, and scene lifecycle hooks.

## `manifest.json` — full schema

```jsonc
{
  // Canonical mod identifier (falls back to folder name if omitted)
  "id": "my_mod",
  // Human-readable name shown in the mod menu (required)
  "name": "MyMod",
  // Semver version — used for saves and deduplication
  "version": "1.0.0",
  // Author identifier (arbitrary string)
  "author": "author_name",
  // Short description for the mod menu and catalog
  "description": "What this mod does",
  // Glob patterns for C# files to compile via Roslyn
  "sources": ["src/*.cs", "src/**/*.cs"],
  // Glob patterns for GDScript files (.gd)
  "gd_sources": ["scripts/*.gd"],
  // Catalog category slug or comma-separated combination (e.g. "library, gameplay")
  "category": "library",
  // Glob patterns for native shared libraries (.dll / .so / .dylib)
  "depends_native": ["bin/*.dll"],
  // List of mod IDs that must be loaded before this mod (versioned strings allowed)
  "depends": ["cthangover_game", "ui_base"],
  // Mutually exclusive mod IDs — informational metadata, never enforced (see load-order)
  "conflicts_with": [],
  // Declarative GUI entries anchored to shell slots (panel / overlay)
  "ui": [
    { "id": "my_panel", "kind": "panel", "slot": "ModBeforeUIPanel", "scene_path": "ui/my_panel.tscn" }
  ],
  // Title-screen buttons opening fullscreen mod scenes
  "main_menu": [
    { "id": "my_screen", "scene_path": "menu/my_screen.tscn", "button": { "locale": "mymod/menu_button" } }
  ]
}
```

### Field reference

| Field | Required | Description |
|---|---|---|
| `id` | No | Canonical mod identifier (falls back to folder/zip name) |
| `name` | Yes | Human-readable mod name |
| `version` | No | Semver version string (e.g. "1.0.0"). Used by the save system and for deduplication |
| `author` | No | Author identifier |
| `description` | No | Short description |
| `sources` | No | Glob patterns for C# files to compile via Roslyn |
| `gd_sources` | No | Glob patterns for `.gd` GDScript files to compile via Godot's engine |
| `depends` | No | List of mod IDs that must be loaded before this mod (versioned strings allowed — see Load Order) |
| `category` | No | Catalog category slug or comma-separated combination (e.g. `"library, gameplay"`). See the catalog page for the full list |
| `depends_native` | No | Glob patterns for native shared libraries (`.dll` / `.so` / `.dylib`). Copied to the mod cache and wired into P/Invoke resolution by `ModNativeLoader` |
| `conflicts_with` | No | Mutually exclusive mod IDs. **Informational only** — parsed and copied into catalog exports, never enforced at runtime (see the load order warning) |
| `ui` | No | Declarative GUI entries anchored to named shell slots — toolbar-toggled `panel`s and code-driven `overlay`s, collected by `UiRegistry` |
| `main_menu` | No | Title-screen buttons; each opens a fullscreen mod `.tscn`, collected by `MenuRegistry` (section `mods`) |

### Sources — glob patterns

- `"src/*.cs"` — all `.cs` files in `src/`, one level deep
- `"src/**/*.cs"` — all `.cs` files recursively

### GDScript sources — `gd_sources`

- `"scripts/*.gd"` — all `.gd` files in `scripts/`, one level deep

GDScript files are compiled by Godot's GDScript engine (no Roslyn needed). A single `.gd` file can implement multiple interfaces simultaneously — actions, conditions, battle executors, item actions, and mod initializers. See GDScript in Mods for the full API reference.

### Dependencies

```jsonc
"depends": ["cthangover_game"]
```

The kernel resolves dependencies in topological order. Circular dependencies cause a load error. `cthangover_game` is always loaded first.

### Scene lifecycle hooks

There is no `subscriptions` manifest field anymore — it was removed from the schema. To run mod code when a scene loads or unloads:

- declare scene events in a `.scenario` file — see Scenes and Scenarios;
- or annotate a C# class with the `[SceneEvent]` attribute — see Scene Event Metadata.

To attach mod **UI** to a scene or the shell, use the `ui` array (see the field table above and UI Wrappers):

## Real example — Cooking mod

From `mods/cooking/manifest.json`:

```jsonc
{
  "name": "Cooking",
  "author": "ct",
  "description": "Cooking mechanic - full autonomous mod",
  "sources": ["src/*.cs"],
  "ui": [
    // Workbench overlay for the kitchen — toggled by its own code / ui.toggle_panel
    { "id": "cooking_workbench", "kind": "overlay", "scene": "home_kitchen", "slot": "ModLastPanel",
      "scene_path": "scenes/cooking_panel.tscn", "once": true, "show_on_start": false, "priority": 10 },
    // Ration statistics — shown automatically on entering the kitchen
    { "id": "ration_stats", "kind": "overlay", "scene": "home_kitchen", "slot": "Lastground",
      "scene_path": "scenes/ration_statistics_panel.tscn", "once": false, "show_on_start": true, "priority": 20 }
  ]
}
```

Two overlays are anchored to the `home_kitchen` scene: the cooking workbench lives for the whole session (`once`) and shows itself when its code toggles it; the ration statistics panel is created on scene entry (`once: false`) and shown immediately (`show_on_start: true`).

## Real example — Multiple dependencies

From `mods/cooking/manifest.json`:

```jsonc
{
  "id": "cooking",
  // cthangover_game, interface, items_base and time_base load before this mod
  "depends": [ "cthangover_game>=1", "ui_base>=1", "items_base>=1", "time_base>=1" ]
}
```

## Test suites (`test_suites`)

Integration tests live inside the mod they test. A host manifest lists its suites; each suite folder
(`test_suites/<id>/`) holds an ordinary `manifest.json` and is mounted as the virtual mod `<host>__<id>` only
when a user arg starts with one of its `activate` prefixes (or `--with-test-suites`):

```jsonc
// mods/interactive_base/manifest.json
"test_suites": [ { "id": "interactives", "activate": ["--itest=interactives"] } ]

// mods/interactive_base/test_suites/interactives/manifest.json — depends on the host implicitly
{ "sources": ["src/*.cs"], "depends": ["cthangover_game>=1", "dialog_base>=1"] }
```

A suite may depend on mods its host must not (here the host sits below `cthangover_game`). A normal launch, a
game and `--all-mods` never load a suite. `"fixture": true` marks a whole mod as a test fixture: it is loaded
only when named in `--with-mods=`.

## Creating a new mod — minimal manifest

```jsonc
{
  "name": "MyFirstMod",
  "author": "you",
  "description": "Adds a custom item and a new scenario option"
}
```

Save as `mods/my_first_mod/manifest.json`. The mod is now loaded by the kernel. Even without `sources` or `ui`, the mod's scenarios, items, and other data files are discovered automatically from their conventional locations.

===== DOC docs/mods/configs/mod_config.md: Mod Config — mod_config.json =====
Mod system configuration loaded from `config/mod_config.json`. Controls file discovery, caching, and assembly compilation.

## File location

```filestree
config/
└── mod_config.json
```

## Full schema

```jsonc
{
  // Cache compiled assemblies to disk after first compilation (code default: true)
  "use_assembly_cache": true,
  // File/directory patterns excluded from catalog export checksum
  "catalog_ignore_patterns": [".*","bin","obj"],
  // Catalog JSON URLs (multiple are merged)
  "catalog_urls": [],
  // File extensions treated as textures
  "texture_extensions": [".png", ".jpg", ".jpeg"],
  // Audio file extensions (OGG and WAV)
  "audio_extensions": [".ogg", ".wav"],
  // Shader file extensions
  "shader_extensions": [".gdshader", ".gdshaderinclude"],
  // Mod subdirectories scanned for textures
  "texture_groups": ["ui", "backgrounds", "avatars", "icons", "items", "cards", "effects", "skills"],
  // Resource cache configuration
  "cache": {
    // Cache root directory (Godot user:// path)
    "root": "user://mod_cache/",
    // Per-category cache size limits (LRU eviction on overflow)
    "max_sizes": {
      "avatars": 64,
      "backgrounds": 64,
      "items": 64,
      "ui/icons": 64,
      "ui": 64,
      "music": 64,
      "sounds": 64,
      "effects": 64,
      "actions": 128,
      "characters": 128,
      "recipes": 128
    }
  }
}
```

## Section reference

### Top-level fields

| Field | Type | Description |
|---|---|---|
| `use_assembly_cache` | bool | Cache compiled mod assemblies to disk (`true` by default in code). Set to `false` to delete cached DLLs and recompile on every launch — useful during mod development |
| `catalog_ignore_patterns` | string[] | File/directory patterns excluded when computing `install_sha256` for catalog export. Default: `[".*"]` excludes all dot-prefixed entries (`.godot/`, `.import/`, `.gitignore`, etc.) |
| `catalog_urls` | string[] | URLs of catalog JSON files to fetch for the online mod browser. Multiple URLs are merged — metadata from highest version wins, `downloads` arrays are combined |
| `texture_extensions` | string[] | File extensions treated as textures |
| `audio_extensions` | string[] | File extensions treated as audio streams |
| `shader_extensions` | string[] | File extensions treated as shader files |
| `texture_groups` | string[] | Mod subdirectories scanned for textures. A file in `mods/{mod}/ui/image.png` is found because `"ui"` is listed here |
| `cache` | object | Cache configuration (see below) |

### `texture_groups`

Each entry in `texture_groups` corresponds to a mod subdirectory where textures are discovered. The code default is `["ui", "backgrounds", "avatars", "icons", "items", "characters", "effects", "skills"]`; the shipped `config/mod_config.json` replaces `characters` with `cards`:

| Group | Mod directory | Purpose |
|---|---|---|
| `ui` | `ui/` | UI textures, themes |
| `backgrounds` | `backgrounds/` | Scene backgrounds |
| `avatars` | `avatars/` | Character portraits |
| `characters` | `characters/` | Character sprites (code default only) |
| `icons` | `icons/` | UI icons |
| `items` | `items/` | Item sprites |
| `cards` | `cards/` | Battle action card images (shipped config) |
| `effects` | `effects/` | Effect textures |
| `skills` | `skills/` | Skill card artwork |

### `cache` section

| Field | Type | Description |
|---|---|---|
| `root` | string | Cache storage path (Godot user path, e.g. `"user://mod_cache/"`) |
| `max_sizes` | object | Per-category cache size limits (LRU eviction) |

### `max_sizes` entries

Each factory type has a maximum cache capacity. When exceeded, the least recently used entry is evicted. The code default for `max_sizes` is an **empty dictionary** — the values below ship in `config/mod_config.json` and override the fallback capacities each factory passes to `CacheConfig.GetCacheSize`.

| Factory | Default | Caches |
|---|---|---|
| `avatars` | 64 | Avatar textures |
| `backgrounds` | 64 | Background textures |
| `items` | 64 | Item textures |
| `ui/icons` | 64 | UI icon textures |
| `ui` | 64 | General UI textures |
| `music` | 64 | Music audio streams |
| `sounds` | 64 | Sound effect audio |
| `actions` | 128 | Character action definitions |
| `characters` | 128 | Character definitions |
| `recipes` | 128 | Recipe definitions |

## How textures are discovered

1. `ModManager` scans each mod's directory
2. For each `texture_groups` entry (e.g. `"avatars"`), it looks in `mods/{mod}/avatars/`
3. Files matching `texture_extensions` (`.png`, `.jpg`, `.jpeg`) are registered in `ModRegistry`
4. Factories (`AvatarFactory`, `BackgroundFactory`, etc.) resolve paths through the registry with caching

===== DOC docs/mods/editor_tools/index.md: Developer Tools =====
The developer tools system allows mods to create custom editor windows and register them in the in-game toolbar. Tools are discovered automatically via reflection, so any mod with C# code can contribute new tool windows without modifying the kernel.

## Architecture

```mermaid
flowchart TD
    subgraph Discovery
        TF[ToolFactory] -->|Reflections.FindAndCreate| ITP[IToolProvider implementations]
        TBF[ToolBoxButtonFactory] -->|Reflections.FindAndCreate| ITBB[IToolBoxButton implementations]
    end

    subgraph Kernel
        MMA[MainMenu] -->|GetAll + AddMenuButton| TF
        TB[ToolBox] -->|GetVisible| TBF
    end

    subgraph Runtime
        MMB[Main-menu tool buttons] -->|Pressed: ShowWindow(CreateWindow())| TW[ToolWindow popup]
        TB -->|AddToolButtons| BTN[Dynamic toolbar buttons]
        BTN -->|Pressed: Get + ShowWindow(CreateWindow())| TW
    end

    subgraph Mod Assembly
        MTP[MyToolProvider : IToolProvider]
        MTB[MyToolBoxButton : IToolBoxButton]
    end

    ITP --> MTP
    ITBB --> MTB
```

Tools bootstrapping happens at startup:

1. `ToolFactory.Instance` scans all loaded assemblies for `IToolProvider` implementations via `Reflections.FindAndCreate`.
2. `ToolBoxButtonFactory.Instance` scans for `IToolBoxButton` implementations.
3. When a mod assembly loads (`ModAssemblyLoader`), both factories call `RegisterAssembly(assembly)` to discover mod-provided tools.
4. `MainMenu` asks every registered tool to append its launcher button to the tool block below **Exit** via `IToolProvider.AddMenuButton`.
5. `ToolBox` dynamically creates HUD toolbar buttons from `ToolBoxButtonFactory.GetVisible()`.

## Core interfaces

### `IToolProvider`

The entry point for a custom tool. Every tool window must have a provider that implements this interface.

| Member | Type | Description |
|---|---|---|
| `Id` | `string` | Unique identifier used as the registry key (e.g. `"scenario_editor"`) |
| `LocaleKey` | `string` | Translation key for the tool's display name, resolved via `TranslationServer.Translate` |
| `CreateWindow()` | `Window` | Creates a new `Window` instance for this tool. The caller is responsible for adding it to the scene tree and displaying it |
| `AddMenuButton(Container)` | `void` | Appends the tool's launcher button to the main menu's tool block. Use `ToolMenuButton.Attach(container, this)` for the standard button (localized caption, opens the window on press, retranslated on language change) |

### `IToolBoxButton`

Optional companion interface. Implement it alongside `IToolProvider` to add a persistent button on the in-game HUD toolbar that opens your tool.

| Member | Type | Description |
|---|---|---|
| `ToolId` | `string` | Matches an `IToolProvider.Id` in `ToolFactory`, wiring the button to a specific tool |
| `IconPath` | `string` | Resource path to the icon texture loaded for this button |
| `LocaleKey` | `string` | Translation key for the button's tooltip or accessible label |
| `IsVisible()` | `bool` | Return `false` to hide the button dynamically (e.g. dev-only buttons in release builds) |

`IconPath` and `LocaleKey` **are** consumed by `ToolBox.AddToolButtons`: the locale key is
translated into the button's tooltip/label, and the icon is loaded via `ResourceLoader`
when the path exists (a button without a usable icon falls back to its text label).
`ToolId` wires the press to `ToolFactory.Get(toolId)` and opens the tool through
`ToolWindow.ShowWindow(tool.CreateWindow())`.

## `ToolWindow` base class

Abstract base class for tool popup windows. Extends Godot's `Window`. Key features:

- **Custom chrome** — the native border is removed and replaced with a custom
  title bar (title label, maximize toggle, close button) plus drag-to-move on
  the title bar and dedicated edge/corner resize zones around all four sides.
- **Maximize toggle** — via the title-bar button or double-clicking the title;
  maximized fills the game viewport, restore returns to the previous geometry.
- **Sizing limits** — `MinSize` is 800×400; `MaxSize` equals the game viewport
  size, so tool windows never exceed the game window.
- **Dirty-state tracking** — `SetDirty()` / `MarkClean()` add/remove a `*` prefix on the title bar. On close with unsaved changes, a confirmation dialog appears automatically.
- **Confirmation dialogs** — `ShowUnsavedDialog(Action)` shows a localized "unsaved changes" prompt; `ConfirmDiscardUnsaved()` provides a return-value variant.
- **Layout helpers**:
  - `CreateFillContainer()` — full-anchor `VBoxContainer` filling the entire window
  - `CreateToolbar()` — pre-configured `HBoxContainer` with 8px spacing
  - `CreateSidebar(VBoxContainer, int width = 280)` — fixed-width `PanelContainer` with vertical scrolling
- **Monospace font** — `GetMonospaceFont()` resolves a monospace font for code displays, falling back to `ThemeDB.FallbackFont`.
- **Static openers** — `Open<T>()` (generic, parameterless constructor) and `ShowWindow(Window)` (for `IToolProvider`-created instances) center the window on screen. Both apply the chrome via `SetupChrome()` before showing.
- **Teardown hook** — override the virtual `Cleanup()` to release resources; it is called automatically from `_ExitTree` when the window leaves the tree.

## Registry

### `ToolFactory`

Singleton registry (`ToolFactory.Instance`) for `IToolProvider` implementations.

| Method | Description |
|---|---|
| `Register(IToolProvider)` | Adds a tool; duplicates by `Id` are silently ignored (first-wins) |
| `RegisterAssembly(Assembly)` | Scans an assembly for `IToolProvider` implementations and registers them — the primary hook for mods |
| `Get(string id)` | Looks up a tool by `Id`, returns `null` if not found |
| `GetAll()` | Returns all registered tools as a read-only list — used by `MainMenu` to ask each tool for its menu button |

Logging uses the `"TOOLS"` category.

### `ToolBoxButtonFactory`

Singleton registry (`ToolBoxButtonFactory.Instance`) for `IToolBoxButton` implementations.

| Method | Description |
|---|---|
| `Register(IToolBoxButton)` | Adds a button definition (duplicates not filtered — same `ToolId` may have different visibility rules) |
| `RegisterAssembly(Assembly)` | Scans an assembly for `IToolBoxButton` implementations — the primary hook for mods |
| `GetVisible()` | Returns all registered buttons that pass `IsVisible()`, used by `ToolBox.AddToolButtons` |

## HUD integration — `ToolBox`

The `ToolBox` control (`Core/UI/Tool/ToolBox.cs`) is the main toolbar/HUD container. It is
manifest-driven and knows no concrete panels itself: `UiBuilder` hands it one
`UiEntry` per mod-declared toolbar panel (from `UiRegistry.PanelEntries`, pre-sorted by
button index, mod load order, then id), and `ApplyPanelButtons(entries)` builds a styled
button per entry — icon via `UIIconFactory`, label from the entry's `button.locale` key —
whose press calls `UiRegistry.ToggleEntry(id)` to show/hide the panel.

Tool windows ride the same bar: during `_Ready()`, `AddToolButtons(container)` appends one
button per `ToolBoxButtonFactory.GetVisible()` definition — label from `LocaleKey`, icon
from `IconPath` when the resource exists — and opens its tool via
`ToolWindow.ShowWindow(tool.CreateWindow())`, so the window gets the full chrome
(title bar, resize zones) and is centered rather than being shown raw from
`CreateWindow()` alone.

## Built-in tools

The `editor_tools` mod provides six development tools:

| Tool ID | Description | Locale Key |
|---|---|---|
| `scenario_editor` | Full editor for `.scenario` DSL files — file tree, text/graph tabs, metadata inspection, background thumbnail previews, play-test mode | `tools/scenario_editor/title` |
| `scene_builder` | Interactive Godot scene inspector — wrapper template picker, live viewport with node hierarchy tree, C# code editor with compile-and-run | `tools/scene_builder/title` |
| `light_editor` | Edits static lights on backgrounds — light list (max 10), per-light radius/influence/color editors, draggable handles, shader preview, JSON export/import | `tools/light_editor/title` |
| `interactive_editor` | Creates/edits interactive object definitions — BG preview with draggable collider handles (rect, circle, polygon), JSON output, load/save to mod directories | `tools/interactive_editor/title` |
| `map_editor` | Visual world-map zone editor — canvas with draggable regions/markers/labels per scene, locked/hidden flags, per-zone title keys and images, load/save of the zone JSON (see Map & Zones) | `tools/map_editor/title` |
| `catalog_export` | Exports a catalog entry (`catalog.json`) for a mod — download URL, category, tags, changelog, status, min game version, icon URL | `mods/catalog_title` |

Each tool has a provider class (`ScenarioEditorToolProvider`, `SceneBuilderToolProvider`, `LightEditorToolProvider`, `InteractiveEditorToolProvider`, `MapEditorToolProvider`, `CatalogExportToolProvider`) implementing `IToolProvider`, and a dedicated window class extending `ToolWindow`.

Three tools also provide `IToolBoxButton` implementations for quick access from the HUD toolbar:
- `LightEditorToolBoxButton` — always visible
- `InteractiveEditorToolBoxButton` — always visible
- `MapEditorToolBoxButton` — always visible (map authoring is part of the normal dev workflow)

## Pages

- Creating a Custom Tool — step-by-step guide with code examples and locale keys

===== DOC docs/mods/editor_tools/tutorial.md: Creating a Custom Tool =====
Implement a tool in your mod with three classes:

## 1. Provider — `IToolProvider`

```csharp
using Cthangover.Core.UI.Tool;
using Godot;

namespace MyMod
{
    public class MyToolProvider : IToolProvider
    {
        public string Id => "my_tool";
        public string LocaleKey => "tools/my_tool/title";
        public Window CreateWindow() => new MyToolWindow();

        // Adds the launcher button to the main menu's tool block (below Exit).
        public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
    }
}
```

## 2. Window — extends `ToolWindow`

```csharp
using Cthangover.Core.UI.Tool;
using Godot;

namespace MyMod
{
    public class MyToolWindow : ToolWindow
    {
        public MyToolWindow() : base("tools/my_tool/title")
        {
            var root = CreateFillContainer();
            AddChild(root);

            var toolbar = CreateToolbar();
            root.AddChild(toolbar);

            var closeBtn = new Button { Text = TranslationServer.Translate("tools/my_tool/close") };
            closeBtn.Pressed += () => QueueFree();
            toolbar.AddChild(closeBtn);
        }
    }
}
```

## 3. Toolbar button (optional) — `IToolBoxButton`

```csharp
using Cthangover.Core.UI.Tool;

namespace MyMod
{
    public class MyToolBoxButton : IToolBoxButton
    {
        public string ToolId => "my_tool";
        public string IconPath => "";
        public string LocaleKey => "tools/my_tool/title";
        public bool IsVisible() => true;
    }
}
```

## 4. Add locale keys

In your mod's `locale/ui_en.properties`:

```properties
tools/my_tool/title = My Custom Tool
tools/my_tool/close = Close
```

## Registration checklist

| Step | Action |
|---|---|
| 1 | Create provider class implementing `IToolProvider` with a unique `Id` and an `AddMenuButton` that attaches the launcher button |
| 2 | Create window class extending `ToolWindow` — use `CreateFillContainer`, `CreateToolbar`, etc. |
| 3 | (Optional) Create `IToolBoxButton` class for a persistent HUD button |
| 4 | Add locale keys in your mod's `locale/ui_en.properties` |
| 5 | Ensure your mod's `manifest.json` has `"sources"` covering the new `.cs` files |

The tool is auto-discovered — no manual registration needed. The provider and button classes are found via reflection when the mod assembly loads.

## Locale keys reference

Common UI locale keys used by the tool system:

| Key | Context |
|---|---|
| `tools/unsaved_title` | Unsaved changes dialog title |
| `tools/unsaved_text` | Unsaved changes dialog body |

Tool-specific keys (`tools/<tool_id>/...`) live in the tool mod's own locale files.

## Related

- Source Code in Mods — C# compilation and mod bootstrapping
- Scenes — scene lifecycle and how scenes reference tools
- Interactive Objects — `interactive_editor` creates these definitions
- Locale — `.properties` files for tool UI strings

===== DOC docs/mods/events/index.md: Event Bus =====
The **ModEventBus** is a global publish-subscribe event bus that allows mods to react
to game events and communicate with each other without direct dependencies.

It is registered as a Godot autoload, accessible from both C# and GDScript.

## Built-in engine events

The engine publishes these events automatically. Each event has a string key
(GDScript layer) and a typed C# class.

### Lifecycle events (ordered by priority)

| Event | Key | Priority | Fires when |
|-------|-----|----------|------------|
| `SceneExitedEvent` | `"scene_exited"` | lifecycle | Leaving the current scene |
| `SceneEnteredEvent` | `"scene_entered"` | lifecycle | New scene loaded, root in tree |
| `BattleStartedEvent` | `"battle_started"` | lifecycle | Battle core initializes *(declared by `turn_battle_base`)* |
| `BattleEndedEvent` | `"battle_ended"` | lifecycle | Battle ends (victory/defeat) *(declared by `turn_battle_base`)* |

Lifecycle events support subscriber **priorities**: lower numbers fire first.
Engine subscribers use these priorities:

| Subscriber | Priority | Purpose |
|------------|----------|---------|
| `SceneManager` internals | 0 | Scene background (`ApplySceneDefaults`) |
| `SceneManager` internals | 5 | Audio (`PlaySceneAmbient`) |
| `SceneManager` internals | 10 | Music (`UpdateMusicType`) |
| `UiHost` | 30 | Shows/hides scene-scoped manifest `ui` elements on enter/exit |
| `SceneContextNode` | 0 (default) | Clears cached node lookups and interactive objects on `scene_exited` |

Note that scenario event composition (`ComposeEvents`) is **not** a bus subscriber — it runs synchronously inside `SceneManager.SwitchScene`.

### Game state events

| Event | Key | Payload |
|-------|-----|---------|
| `CharacterDiedEvent` | `"character_died"` | `character_id`, `character_name`, `source` ("battle"/"world") *(declared by `turn_battle_base`)* |
| `CharactersDefeatedEvent` | `"characters_defeated"` | `source`, `character_ids` — a group defeat (battle won, trap); subscribers like the recruiting mod resolve ids through `CharacterFactory` |
| `QuestStatusChangedEvent` | `"quest_status_changed"` | `quest_id`, `old_status`, `new_status` |
| `QuestTagChangedEvent` | `"quest_tag_changed"` | `quest_id`, `tag`, `added` (bool) |
| `QuestDataStatusChangedEvent` | `"quest_data_status_changed"` | `old`, `new` — numeric quest progress counter; carries no quest id (a change marker for condition caches) |
| `QuestStateRestoredEvent` | `"quest_state_restored"` | (empty) — published once after a save's quest state is fully restored |
| `FlagChangedEvent` | `"flag_changed"` | `key`, `value` — a scenario condition flag changed (`ScenarioCondition.SetFlag`) |
| `InteractiveStateChangedEvent` | `"interactive_state_changed"` | `id` — an interactive object was removed (not published on add, so `interactive_add` re-runs stay idempotent) |
| `MapZoneChangedEvent` | `"map_zone_changed"` | `zone_id` (empty for bulk changes), `change` — a map zone's open/visible state changed |
| `InventoryChangedEvent` | `"inventory_changed"` | `item_id`, `delta`, `new_count` |
| `TimeTickEvent` | `"time_tick"` | `time_tick`, `normalized_time` |
| `NewGameStartedEvent` | `"new_game_started"` | (empty) — published by `GameData.StartNewGame` after runtime state is wiped; mods re-seed per-session data here |
| `RecruitJoinedEvent` | `"recruit_joined"` | `recruit_id`, `recruit_name` *(declared by the `recruiting` mod, not yet published)* |
| `RecruitLeftEvent` | `"recruit_left"` | `recruit_id`, `recruit_name` *(declared by the `recruiting` mod, not yet published)* |

### UI events

| Event | Key | Payload |
|-------|-----|---------|
| `DialogStartedEvent` | `"dialog_started"` | `dialog_id`, `speaker` *(declared, not yet published by the engine)* |
| `DialogEndedEvent` | `"dialog_ended"` | `dialog_id` *(the class declares it, but the engine currently publishes an empty instance)* |
| `NotificationRequestedEvent` | `"notification_requested"` | `text`, `category` |
| `ShellReadyEvent` | `"shell_ready"` | `root` — the UI shell finished building; `UiHost` attaches mod panels on this event |

### Mod lifecycle events

| Event | Key | Payload |
|-------|-----|---------|
| `ModLoadedEvent` | `"mod_loaded"` | `mod_id` |
| `ModResourcesReadyEvent` | `"mod_resources_ready"` | (empty) |

## C# API

### Subscribe to a typed event

```csharp
using Cthangover.Core.Events;

ModEventBus.Subscribe<SceneEnteredEvent>(e =>
{
    GameLogger.Log("MY_MOD", $"Scene entered: {e.SceneName}");
}, priority: 0, modId: "my_mod");
```

- `handler` — callback receiving the typed event.
- `priority` — only meaningful for `[LifecycleEvent]` classes (lower = earlier). Default 0.
- `modId` — optional, used for cleanup via `ClearForMod(modId)` on unload.

### Publish a custom event

```csharp
[EventKey("my_mod:custom_event")]
public class MyCustomEvent : ModEvent
{
    public string Data { get; init; }
}

// Publish:
ModEventBus.Publish(new MyCustomEvent
{
    SenderModId = "my_mod",
    Data = "hello"
});
```

Other mods subscribe by:

```csharp
ModEventBus.Subscribe<MyCustomEvent>(e =>
{
    GameLogger.Log("OTHER_MOD", $"Received: {e.Data}");
});
```

GDScript mods subscribe via the string key `"my_mod:custom_event"`.

### Unsubscribe

```csharp
Action<MyCustomEvent> handler = e => { /* ... */ };
ModEventBus.Subscribe(handler);
// later:
ModEventBus.Unsubscribe(handler);
```

## GDScript API

### Auto-registration (convention-based)

GDScript objects loaded by `ModGDScriptLoader` are automatically scanned for
methods starting with `on_mod_event_`. Each such method is subscribed to the
string event bus under the key after the prefix.

```gdscript
# scripts/my_handler.gd
func on_mod_event_scene_entered(data: Dictionary) -> void:
    print("Entered scene: " + data.scene_name)

func on_mod_event_battle_ended(data: Dictionary) -> void:
    print("Battle ended: " + data.result)

func on_mod_event_cooking_meal_ready(data: Dictionary) -> void:
    if data.dish == "wolf_stew":
        boost_morale()
```

The method receives a `Dictionary` with snake_case keys (e.g. `scene_name`,
`item_id`, `old_status`) matching the typed event properties.

### Manual subscription

```gdscript
ModEventBus.subscribe("scene_entered", _on_scene, 0, "my_mod")

func _on_scene(data: Dictionary) -> void:
    print(data.scene_name)
```

### Publishing from scenario actions

GDScript scenario actions can publish events via `ctx.PublishEvent()`:

```gdscript
func get_name() -> String:
    return "cooking.meal_cooked"

func run(ctx) -> void:
    ctx.PublishEvent("cooking_meal_ready", {
        "dish": ctx.GetParam("dish"),
        "quality": ctx.GetParam("quality")
    })
```

### Publishing directly

```gdscript
ModEventBus.publish("cooking_meal_ready", { "dish": "wolf_stew", "quality": 0.8 })
```

## Error isolation

Each handler is executed in a try/catch. An error in one handler does **not**
prevent other handlers from executing. Errors are logged to the `EVENT_BUS` category.

## Cleanup

When a mod is reloaded or unloaded, its subscriptions are removed:

```csharp
ModEventBus.Instance.ClearForMod("my_mod");
```

This covers both typed (C#) and string (GDScript) subscriptions for that mod.
GDScript mods automatically get `ClearForMod` called on reload via `ModGDScriptLoader`.

## Typed layer reference

The typed C# layer lives in `Core/Events/ModEventBus.cs`. All three methods are static and constrained to `T : ModEvent`:

| Method | Signature | Notes |
|---|---|---|
| `Subscribe<T>` | `Subscribe<T>(Action<T> handler, int priority = 0, string modId = null)` | `priority` is only meaningful for `[LifecycleEvent]` classes (lower = earlier); ignored otherwise |
| `Unsubscribe<T>` | `Unsubscribe<T>(Action<T> handler)` | Removes a single typed handler |
| `Publish<T>` | `Publish<T>(T evt)` | Calls all typed handlers first, then auto-bridges to the string layer |

### Auto-bridge via `[EventKey]`

On `Publish<T>`, the bus reads the event class's `[EventKey("snake_case_key")]` attribute (`Core/Events/EventKeyAttribute.cs`) and broadcasts a `Dictionary` on that key so GDScript subscribers receive the same event. Public instance properties **declared on the event class itself** become snake_case keys (`SceneName` → `scene_name`); inherited `ModEvent` members are not included, and `Node` references pass through unwrapped. An event class without `[EventKey]` does not bridge — only typed C# subscribers receive it.

### `[LifecycleEvent]` and delivery order

Marking an event class with `[LifecycleEvent]` (`Core/Events/LifecycleEventAttribute.cs`) enables priority-ordered delivery: every `Subscribe<T>` call re-sorts that event's subscriber list by `priority`, so lower numbers fire first. Without the attribute, handlers run in subscription order.

### Base class `ModEvent`

All typed events derive from `ModEvent` (`Core/Events/ModEvent.cs`), which carries two members available to every handler:

| Member | Type | Meaning |
|---|---|---|
| `SenderModId` | `string` | Sending mod (`null` = engine) |
| `Tick` | `long` | Game-time tick at publish time (from `GameData.Instance.Runtime.Time`); `0` if `GameData` is not yet initialized |

### Cleanup API

- `ClearForMod(string modId)` — removes all subscriptions registered with that `modId`, across both the typed and string layers. Called on mod unload/reload.
- `ClearAll()` — wipes every subscription on both layers.

===== DOC docs/mods/interactives/index.md: Interactive Objects =====
Interactive objects are clickable scene elements with hover effects and custom actions. They are added to scenes from scenario scripts via `interactive_add` and defined as JSON files.

## JSON structure

```jsonc
{
  "Items": [{                                              // Array of interactive object definitions
    "id": "kitchen_door",                                  // Unique identifier for interactive_add / interactive_remove commands
    "texture": "interactives/kitchen_door",                // Texture key resolved via ModManager.ResolveTexture
    "layer": "foreground",                                 // Visual layer: background, foreground, or ui
    "zIndex": 10,                                          // Z-ordering within the layer (higher = on top)
    "enabled": true,                                       // Whether the object responds to input
    "visible": true,                                       // Whether the object is rendered
    "cursor": "PointingHand",                              // Mouse cursor override (Godot CursorShape enum name)
    "hitArea": {                                           // Collider shape and position in normalised 0..1 coordinates
      "type": "rect",                                      // Shape type: rect, circle, or polygon
      "x": 0.7,                                            // X position (top-left for rect, centre for circle)
      "y": 0.3,                                            // Y position
      "width": 0.15,                                       // Width (rect type only)
      "height": 0.5                                        // Height (rect type only)
    },
    "highlight": {                                         // Visual highlight settings on hover
      "color": "#FFFF0033",                                // Modulate colour as hex string
      "scale": 1.02,                                       // Pulse scale multiplier on hover (1.02 = 2% bigger)
      "duration": 0.15                                     // Animation duration in seconds for hover/unhover
    },
    "actions": {                                           // Actions triggered by pointer events
      "onClick": {                                         // Action on mouse click
        "scenario": "scenarios/home/kitchen_door.scenario" // Path to scenario file to run on click
      },
      "onHoverEnter": "",                                     // Inline DSL commands executed on mouse enter
      "onHoverLeave": ""                                   // Inline DSL commands executed on mouse leave
    }
  }]
}
```

## Field reference

### Top-level fields

| Field | Type | Default | Description |
|---|---|---|---|
| `id` | string | *(required)* | Unique identifier used in `interactive_add` / `interactive_remove` commands |
| `texture` | string | *(required)* | Texture key resolved via `ModManager.ResolveTexture`. Full-screen with alpha |
| `layer` | string | `"foreground"` | Visual layer: `"background"`, `"foreground"`, or `"ui"` |
| `zIndex` | int | 0 | Z-ordering within the layer (higher = on top) |
| `enabled` | bool | `true` | Whether the object responds to input |
| `visible` | bool | `true` | Whether the object is rendered |
| `cursor` | string | — | Mouse cursor override (Godot `CursorShape` enum name, e.g. `"PointingHand"`) |
| `hitArea` | object | `{ type: "rect" }` | Collider shape and position |
| `highlight` | object | — | Visual highlight settings on hover |
| `actions` | object | — | Actions triggered by pointer events |

### `hitArea` fields

Coordinates are in **normalised viewport coordinates (0..1)**, where `(0,0)` = top-left, `(1,1)` = bottom-right.

| Field | Type | Default | Description |
|---|---|---|---|
| `type` | string | `"rect"` | Shape type: `"rect"`, `"circle"`, or `"polygon"` |
| `x` | float | 0 | X position (top-left for rect, centre for circle) |
| `y` | float | 0 | Y position |
| `width` | float | 0.1 | Width for rect type |
| `height` | float | 0.1 | Height for rect type |
| `radius` | float | 0.05 | Radius for circle type |
| `vertices` | array | — | `[{x, y}, ...]` polygon vertices (normalised) |

### `highlight` fields

| Field | Type | Default | Description |
|---|---|---|---|
| `color` | string | `"#FFFF0033"` | Modulate colour as a hex string (e.g. `"#FF000044"`) |
| `scale` | float | 1.02 | Pulse scale multiplier on hover (e.g. 1.02 = 2% bigger) |
| `duration` | float | 0.15 | Animation duration in seconds for hover/unhover |

### `actions` fields

| Field | Description |
|---|---|
| `onClick` | `ClickAction` — path to `.scenario` and/or inline DSL commands |
| `onHoverEnter` | Inline DSL commands executed on mouse enter |
| `onHoverLeave` | Inline DSL commands executed on mouse leave |

### `onClick` sub-fields

| Field | Description |
|---|---|
| `scenario` | Path to a `.scenario` file to run on click |
| `commands` | Array of inline DSL command strings to execute on click |

## Scenario commands

| Command | Description |
|---|---|
| `interactive_add object_id` | Instantiate an interactive object by its definition ID |
| `interactive_remove object_id` | Remove an interactive object |
| `interactive_set object_id enabled=true\|false` | Enable/disable or show/hide an object. Named params: `enabled`, `visible` |
| `interactive_clear` | Remove all interactive objects from the scene |

## Directory structure

```filestree
interactives/
├── kitchen_door.json
└── treasure_chest.json
```

There is no index file. `InteractiveResolver.CollectInteractiveDefsRecursive` scans every `*.json` file under the mod's `interactives/` folder recursively (including subfolders) and reads each one as a `{"Items": [...]}` envelope.

## Adding a new interactive object

1. Create `interactives/{id}.json` with the full definition — it will be picked up automatically
2. In a scenario, activate it: `interactive_add my_object`

===== DOC docs/mods/items/index.md: Items =====
JSON-defined items — consumables, resources, and recipe items — with localization, pricing, and crafting recipes.

## JSON structure

```jsonc
{
  // Unique item ID in path-style format (e.g. "food/ration")
  "Id":          "type/item_id",
  // Locale key for the item's display name
  "Name":        "locale_key_for_name",
  // Locale key for the item's tooltip/description
  "Description": "locale_key_for_description",
  // Base purchase price in the game economy
  "Cost":        5,
  // Path to the item icon sprite, resolved under the mod's sprites directory
  "Sprite":      "type/item_id",
  // Type flags (comma-separated): Food, Resource, Quest, Recipe, Used, TargetUsed
  "ItemType":    "Food,Resource",
  // Action ID for the item's use effect; resolved via IItemAction factory
  "ItemAction":  "action_id"
}
```

## Field reference

| Field | Description |
|---|---|
| `Id` | Unique item ID, path-style (`"food/ration"`) |
| `Name` | Locale key resolved from `.properties` files |
| `Description` | Locale key for tooltip/info text |
| `Cost` | Base price in the game economy |
| `Sprite` | Path to the item icon under the mod's sprites directory |
| `ItemType` | Type tag(s), comma-separated: `Food`, `Resource`, `Quest`, `Recipe`, `Used`, `TargetUsed` |
| `ItemAction` | Action ID resolved via `IItemAction` factory — can be a C# class or a GDScript with `get_item_action_id()` + `use(item)`. Defines the item's use effect |

### ItemType values

`ItemType` is a `[Flags]` enum — multiple values can be combined with a comma:

| Value | Hex | Description |
|---|---|---|
| `Quest` | `0x01` | Quest item — tracked in quest log |
| `Used` | `0x02` | Self-targeting use item |
| `TargetUsed` | `0x04` | Ally/enemy-targeting use item |
| `Food` | `0x08` | Edible (consumes hunger stat) |
| `Resource` | `0x10` | Crafting material |
| `Recipe` | `0x20` | Recipe item (unlocks in cookbook) |
| `CantDrop` | `0x80000000` | Cannot be discarded from inventory |

### Extra JSON keys

Any JSON key that does not match a DTO field is captured by
`ItemInfo.ExtraProperties` (`[JsonExtensionData]`) and imported by
`ItemFactory` into `Item.Properties` — a string-keyed `PropertyData` bag
(`Core/Items/ItemInfo.cs`, `Core/Items/Item.cs`). Numbers import as int/float,
booleans as bool, strings as strings; arrays and objects are stored as raw JSON
text. Core code never reads `Properties` — the bag exists so your own C# item
actions or scenario logic can attach custom data to items without schema
changes.

```jsonc
{
  "Id": "food/ration",
  "...": "...",
  // Captured into Properties — readable from your own code via item.Properties.GetInt("MyMod/Saturation")
  "MyMod/Saturation": 3
}
```

## Item actions (IItemAction)

The `ItemAction` field references an implementation of `IItemAction`
(`Core/Items/IItemAction.cs`) — the item's on-use behaviour:

```csharp
public class MyHealAction : IItemAction
{
    // Registry lookup key — must match the item's "ItemAction" field in JSON
    public string ID => "mymod/heal";

    public bool UseAction(IItem item)
    {
        // Returning FALSE aborts consumption — the item stays in the
        // inventory and the UI can show a failure feedback instead
        return TryHeal(item);
    }
}
```

Registration options:

- **Auto-discovery** — on first use, `ItemActionFactory` scans every loaded
  assembly for public `IItemAction` implementations and indexes them by `ID`.
  A mod DLL just needs to contain the class — no registration code required.
- **Manual registration** — `ItemActionFactory.Register(IItemAction)` adds a
  single instance; `ItemActionFactory.RegisterAssembly(assembly)` scans one
  assembly (used for hot-loaded mod assemblies).

The special ID `"null"` (or an empty value) is an explicit no-action marker:
`ItemActionFactory.Get` returns `null` for it, meaning the item has no use
effect.

## Real examples

### Consumable — `cthangover_game/items/food/ration.json`

```jsonc
{
  // Unique item ID
  "Id": "food/ration",
  // Locale key → "Ration"
  "Name": "items/food/ration/name",
  // Locale key → "A daily ration unit for one person"
  "Description": "items/food/ration/desc",
  // Base price: 5 gold
  "Cost": 5,
  // Icon sprite path under the mod's sprites folder
  "Sprite": "food/ration",
  // Edible item (consumes hunger stat on use)
  "ItemType": "Food"
}
```

### Crafting material — `cthangover_game/items/food/wolf_meat.json`

```jsonc
{
  // Unique item ID
  "Id": "food/wolf_meat",
  // Locale key → "Wolf cutlet"
  "Name": "items/food/wolf_meat/name",
  // Locale key → "Raw wolf meat"
  "Description": "items/food/wolf_meat/desc",
  // Base price: 1 gold — cheap, but important for recipes
  "Cost": 1,
  // Icon sprite path
  "Sprite": "food/wolf_meat",
  // Both a crafting ingredient (Resource) and edible (Food)
  "ItemType": "Resource,Food"
}
```

Note: `ItemType` can be multiple values — `"Resource,Food"` means it's both a crafting ingredient and edible.

### Recipe item

Recipe items are items that represent a craftable recipe in the player's inventory:

```jsonc
{
  // Unique recipe item ID
  "Id": "recipe/wolf_meat_to_ration",
  // Locale key for the recipe name
  "Name": "recipe/wolf_meat_to_ration/name",
  // Locale key for the recipe description
  "Description": "recipe/wolf_meat_to_ration/desc",
  // Icon sprite path
  "Sprite": "recipe/wolf_meat_to_ration",
  // Recipe type — unlocks in the cookbook when acquired
  "ItemType": "Recipe"
}
```

## Item file format

Every item file is a self-describing envelope routed by `Kind` — the file may
live anywhere in the mod and there is no index file:

```jsonc
{
  // Entity kind — routing discriminator for the shared entity resolver
  "Kind": "item",
  "Items": [
    {
      // Unique item ID
      "Id": "food/ration",
      "Name": "items/food/ration/name",
      "Description": "items/food/ration/desc",
      "Sprite": "food/ration",
      "ItemType": "Food"
    }
  ]
}
```

## Recipes

Recipes are their own entity kind and follow the same envelope:

```jsonc
{
  // Recipes are separate entities, not items
  "Kind": "recipe",
  "Items": [{
    // Unique recipe ID
    "Id": "recipe/wolf_meat_to_ration",
    // Locale key for the recipe display name
    "Name": "recipe/wolf_meat_to_ration/name",
    // Locale key for the recipe description text
    "Description": "recipe/wolf_meat_to_ration/desc",
    // Workbench type — "Cooking" is currently the only supported type
    "Type": "Cooking",
    // Crafting time in in-game minutes
    "Time": 15,
    // Required ingredients: Id references an item definition, Count is the quantity needed
    "Input": [
      { "Id": "food/wolf_meat", "Count": 1 },
      { "Id": "resource/branches", "Count": 2 }
    ],
    // Produced items on successful craft
    "Output": [
      { "Id": "food/ration", "Count": 1 }
    ]
  }]
}
```

| Field | Description |
|---|---|
| `Id` | Unique recipe ID |
| `Name` | Locale key for recipe display name |
| `Description` | Locale key for the recipe description text |
| `Type` | Workbench type (`WorkbenchType` enum). The enum currently declares a single value: `"Cooking"` |
| `Time` | Crafting time in in-game minutes |
| `Input` | Required ingredients: `Id` (item ID) + `Count` |
| `Output` | Produced items: `Id` (item ID) + `Count` |

## Inventory API

The player inventory (`Inventory` / `IInventory`, `Core/Items/Inventory.cs`)
is an ordered list of item slots:

- `Items` — `List<IItemContainer>`, each slot being `{ Item, Count }`. Slot
  order drives UI layout; empty containers are deleted when a stack hits zero.
- `Change` — C# event `Action<string itemId, int delta>` fired on every add/remove.

All query and mutation methods are overloaded by **item id string** or by
**`IItem` instance**:

| Method | Description |
|---|---|
| `HasItem(id \| item)` | Does any slot hold this item? |
| `TryGet(id \| item)` | Returns the `IItemContainer` slot, or null |
| `CheckCount(id \| item)` | Total count across slots (0 if absent) |
| `Add(id \| item, count = 1)` | Stacks onto an existing slot, or creates a new one by resolving the id through `ItemFactory`. Unknown ids are silently ignored |
| `Remove(id \| item, count = 1)` | Removes items; returns `false` without changes if the slot holds fewer than `count` |

Every successful mutation publishes an `InventoryChangedEvent` to the global
`ModEventBus` — published from `Inventory.PublishInventoryChanged`
(`Core/Items/Inventory.cs`). GDScript subscribers receive it under the key
`"inventory_changed"` with a snake_case payload:

| Payload key | Type | Meaning |
|---|---|---|
| `item_id` | string | Affected item ID |
| `delta` | int | Signed change amount (negative on removal) |
| `new_count` | int | Slot count after the mutation |

## GDScript bridge objects

GDScripts never touch `IInventory` directly — the engine hands them wrapper
objects:

### `GDInventoryService`

A `[GlobalClass]` `RefCounted` wrapping the inventory. Plain-id methods mirror
the C# API; the `*ByItem` variants take a `GDItem` instead of an id string:

| Method | Description |
|---|---|
| `HasItem(id)` / `HasItemByItem(gd_item)` | Item presence check |
| `CheckCount(id)` / `CheckCountByItem(gd_item)` | Stack size lookup |
| `Add(id, count = 1)` / `AddByItem(gd_item, count = 1)` | Add items |
| `Remove(id, count = 1)` / `RemoveByItem(gd_item, count = 1)` | Remove items; returns success |

### `GDItem`

A `[GlobalClass]` snapshot passed as the `item` argument to a GDScript's
`use(item)`:

| Field | Type | Description |
|---|---|---|
| `Id` | string | Item identifier |
| `Name` | string | Display name |
| `Description` | string | Tooltip text |
| `Cost` | int | Purchase price |
| `ItemType` | int | `ItemType` flags as int |
| *(internal)* | — | The wrapper keeps an internal reference to the real `IItem`; `Sprite` and `Properties` are deliberately not exposed |

See GDScript in Mods → Items for the
authoring-side view.

## Locale keys

Item JSONs reference locale keys — you need to define the actual text in `locale/en.properties`:

```properties
items/food/ration/name = Ration
items/food/ration/desc = A daily ration unit for one person
items/food/wolf_meat/name = Wolf cutlet
items/food/wolf_meat/desc = Raw wolf meat
recipe/wolf_meat_to_ration/name = Ration from wolf meat
recipe/wolf_meat_to_ration/desc = A recipe for cooking wolf meat safely
```

## Adding a new item

1. Create a JSON file anywhere in your mod with the `{ "Kind": "item", "Items": [ ... ] }` envelope and all fields
2. Add locale keys for name and description
3. Optionally add a sprite icon
4. If the item has an active use, reference it via the factory — implement either an `IItemAction` C# class or a GDScript with `get_item_action_id()` + `use(item)`. See GDScript in Mods for details.

===== DOC docs/mods/load-order/index.md: Mod Load Order =====
The mod load order determines which mod's assets and data take priority when two or more
mods define the same resource. The system combines automatic dependency-based ordering with optional manual
user overrides.

## How ordering works

The game builds the final load order from three inputs, applied in strict priority:

```
1. depends (manifest.declared) → 2. Manual order (user preference) → 3. Alphabetical (deterministic fallback)
```

### Layer 1: `depends` — mandatory

Every mod may declare dependencies in `manifest.json` using **versioned dependency strings**:

```jsonc
{
  "id": "cooking",                                         // Canonical mod identifier
  "depends": ["cthangover_game>=1", "ui_base>=1"]                   // Versioned dependency strings
}
```

A dependency string has the format: `modId[operator][version][?]`

#### Operators

| String | Meaning | Example |
|--------|---------|---------|
| `cthangover_game>=1` | version ≥ 1.0.0 | Any core 1.x or higher |
| `cthangover_game=1.0.0` | exact match | Strictly 1.0.0 |
| `cthangover_game^1` | prefix match | 1.x.x — matches 1.0.0, 1.5.0, but not 2.0.0 |
| `cthangover_game>1` | greater than | Any version above 1.0.0 |
| `cthangover_game<2` | less than | Any version below 2.0.0 |
| `cthangover_game<=1` | less or equal | Version ≤ 1.0.0 |
| `cthangover_game` | any version | No version constraint |
| `cthangover_game>=1?` | optional | If core is installed, must be ≥1; no error if absent |

Version comparison is **segment-based** with zero-padding: `1` equals `1.0` equals `1.0.0`.
Segments may contain letters (e.g. `1a`, `alpha-3`). Each segment is compared as integer
if both sides are parseable, otherwise as string (`Ordinal`).

#### Optional dependencies (`?`)

Appending `?` to a dependency makes it **optional**:

```jsonc
{ "depends": ["optional_mod>=1?"] }                        // ? suffix makes this dependency optional
```

- If the optional mod is **not installed** → no error, the dependency is skipped.
- If the optional mod **is installed** → its version must satisfy the constraint.

This is useful for mods that want to react to another mod being present without
requiring it.

#### Strict equality (`=`) vs prefix match (`^`)

`=` requires **all** segments to match (with zero-padding):

- `cthangover_game=1.0` does **not** match `1.0.5` — the third segment `"0"` ≠ `"5"`.
- `cthangover_game=1.0.0` matches `1.0.0`.

`^` matches only the specified segments, ignoring the rest:

- `cthangover_game^1.0` matches `1.0.0`, `1.0.5`, but **not** `1.1.0`.
- `cthangover_game^1` matches `1.0.0`, `1.1.0`, `1.9.0`, but **not** `2.0.0`.

This follows the convention used by npm, Cargo, pip, Factorio, and Minecraft Fabric.

#### `conflicts_with`

Mods can declare mutual incompatibility:

```jsonc
{
  "id": "ff_battle",                                       // Canonical mod identifier
  "conflicts_with": ["card_battle"]                        // Declared incompatible mod IDs — informational only
}
```

!!! warning "Declarative metadata only"
    `conflicts_with` is parsed from the manifest and copied into catalog
    exports, but it is **never enforced at runtime**. Declaring a conflict
    disables nothing, blocks nothing, and shows no UI warning — two mods
    that declare each other as conflicting will still load and run
    together. Treat this field purely as informational metadata (e.g. for
    catalogs and mod platforms); do not rely on it to prevent broken
    combinations.

### Layer 2: Manual order — user preference

The manual order is a saved per-device preference in `user://mod_load_order.json` — it does **not**
transfer between devices or affect other players. Within the same dependency level, mods earlier
in the saved list load earlier, so a later-loaded mod still overrides them under the "last wins"
rule below.

The ordering machinery exists in the kernel (`ModLoadOrderStore` with `MoveUp`/`MoveDown`/`Remove`
helpers, `ModRegistry.InvalidateOrder` to recompute the cached order), but the current
**Mods & Collections** screen does not expose reorder buttons — the row order it displays is the
computed order. A mod whose dependency is missing is dropped from the registry entirely rather
than reordered, so the saved list can never place a dependent above its dependency.

### Layer 3: Alphabetical — deterministic fallback

When two mods have no dependency relationship and no manual order preference,
they are sorted alphabetically by their mod ID. This guarantees the same result
on every device and operating system.

## Conflict resolution strategy

The game uses **"last wins"** — the mod later in the load order overrides earlier mods:

| Asset type | Resolution |
|---|---|
| **Textures** | Same-name texture from a later mod overrides the earlier one |
| **JSON data** (characters, items, recipes, quests, interactives) | Same `ID` entry from a later mod overrides the earlier one |
| **Files** (audio, prefabs, etc.) | Same-path file from a later mod overrides the earlier one |
| **Shaders** | Same-name shader from a later mod overrides the earlier one |
| **Locale strings** | Same key from a later translation file overrides the earlier one |
| **Scene definitions** | Multiple mods can contribute to the same scene — they accumulate |
| **Scenarios** | Different names accumulate and run by their own `priority` and `after` metadata; the **same file name** from a later mod replaces the earlier scenario (even if it targets another scene) |
| **Property arrays** | Arrays in `ExtraProperties` (e.g. `Skills: ["a"]`) are **concatenated** across patches, not overwritten |

### Add or replace — the mod decides

Overriding is a right of a mod, not a duty. Nothing is replaced unless a mod
asks for it, and it asks in one way only:

- **To add**, use your own name: a new scenario for someone else's scene, a new
  item, a new background. It works next to the existing content.
- **To replace**, ship the **same name** (texture, file path, entity `ID`,
  scenario file name) **and declare `depends` on the mod that owns it**. The
  dependency puts your mod later in the load order, so your version wins and
  the original files stay untouched.
- **Same name without a dependency is a mistake**: two unrelated mods have no
  defined order between them, so the winner would be arbitrary. The
  resource atlas (the `resource_atlas` mod, `--resource-atlas`) reports it as an error
  ("one key provided by mods not linked by depends").

When a resource is overridden, a `Warning` is logged so you can inspect the conflict.
Overrides are also recorded in `ModConflictRegistry` (resource type, winning and overridden
mod id, whether arrays were concatenated); the registry is queryable from code and tests,
but the current Mods & Collections screen does not render a conflicts list.

## Version validation

On load, the game validates all dependency versions:

- If a required dependency is missing or its version is too low/too high → the mod is disabled with an error.
- `conflicts_with` is **not validated** — see the warning above; declared conflicts never disable anything.
- Disable reasons are **accumulated** (not overwritten) and shown in the UI status label.
- Cascade: if mod A is disabled, all mods depending on A are also disabled.

## UI overview

The **Mods** tab of the Mods & Collections screen shows one card per discovered mod:

- **Mod name, author, version, category and description**
- **Enable/disable toggle** — disable cascades to dependents; enabling re-enables the dependencies it needs
- **Status** — installed / update available / not installed / modified locally when a catalog entry exists
- **Delete** — offered only for mods whose files live in a writable mods root (`user://mods`);
  takes effect after a restart, like every other mod-set change

Mods are displayed in the computed load order (dependencies first). There is currently
no UI to reorder mods manually — see Layer 2 above.

## Storage

Manual load order is saved to `user://mod_load_order.json` as a JSON array of mod IDs:

```jsonc
["cthangover_game", "cooking", "hunting", "ui_overhaul"]              // Mod IDs in manual load order (first = highest priority)
```

New mods (not yet in the saved order) are placed alphabetically within their
dependency level — no manual position is assigned until the user explicitly reorders them.

Removed mods are cleaned up automatically on the next game launch.

## For mod creators

Declare dependencies with version constraints for safety:

```jsonc
{
  "id": "my_mod",                                          // Canonical mod identifier
  "depends": ["cthangover_game>=1", "ui_base>=1"],                  // Versioned dependency strings
  "version": "1.0.0"                                       // Semver version of this mod
}
```

- Use `>=1` (not `>=1.0.0`) — trailing zeros are unnecessary due to zero-padding comparison.
- Declare `conflicts_with` for documentation/catalog purposes if your mod is incompatible with another — remember it is informational only and not enforced.
- Use `?` for optional soft dependencies.

===== DOC docs/mods/locale/index.md: Locale — Localization =====
All in-game text lives in `.properties` files, organized by mod. The engine resolves locale keys at runtime based on the player's selected language.

## Directory structure

```filestree
locale/
├── en.properties               # UI text, English
├── ru.properties               # UI text, Russian
├── scenarios_en.properties     # Scenario dialogue, English
└── scenarios_ru.properties     # Scenario dialogue, Russian
```

- `{lang}.properties` — general UI text (menus, settings, item names, character names)
- `scenarios_{lang}.properties` — scenario dialogue lines referenced by `key=` parameters

Files are matched by locale code: an exact `{lang}.properties` name or any file
ending with `_{lang}.properties`. Locale files from **all** loaded mods are
merged into one translation, in mod load order — later mods override keys
defined by earlier ones.

## File format

Standard Java `.properties` format:

```properties
# Comment lines start with #
key = value
settings/language = Language
settings/volume = Volume
```

- One `key = value` per line
- Keys use `/` as a namespace separator
- Whitespace around `=` is ignored
- Lines starting with `#` are comments

## Key naming conventions

| Prefix | Purpose |
|---|---|
| `characters/` | Character display names |
| `items/` | Item names and descriptions |
| `rooms/` | Room/scene interaction prompts |
| `quest/` | Quest names and status descriptions |
| `quest/.../tag/` | Quest tag stamps in the journal (`{quest Name}/tag/{tag}`) |
| `ui/` | Interface labels (menus, battle UI, cooking) |
| `settings/` | Settings screen labels |
| `prolog/` | Prologue narrative text |
| `meeting_murakami/` | Murakami quest dialogue |
| `recipe/` | Recipe names and descriptions |

## Real examples

From `mods/cthangover_game/locale/en.properties`:

```properties
# Character names
characters/player/marao = Marao
characters/enemies/wolf = Wolf
characters/enemies/werewolf_girl = Werewolf

# Items
items/food/ration/name = Ration
items/food/ration/desc = A daily ration unit for one person
items/food/wolf_meat/name = Wolf cutlet
items/food/wolf_meat/desc = Raw wolf meat

# Settings
settings/back = Back
settings/audio = Audio
settings/music = Music
settings/language = Language
settings/lang_en = English
settings/lang_ru = Russian

# Battle UI
ui/battle/victory = Victory
ui/battle/next = End turn

# Cooking UI
ui/cook/cook = Cook
ui/cook/close = Close
ui/cook/recipe_time = {0} minutes
```

Keys can include format placeholders (`{0}`, `{1}`) for dynamic data.

## Scenario locale files

Separate `scenarios_{lang}.properties` files contain dialogue lines:

```properties
rooms/kitchen/prompt = A room equipped as a kitchen
rooms/kitchen/opt_exit = Leave
rooms/kitchen/opt_cook = Cook
meeting_murakami/after_wolf/greeting = - Dear lady!
meeting_murakami/after_wolf/help = - Help me...
```

These are referenced in `.scenario` files via the `key=` parameter:

```scenario
text "- Dear lady!" key=meeting_murakami/after_wolf/greeting first=marao/think
```

## Checking keys and encodings

The developer mod `locale_atlas` maps every key of every installed mod per
language and lists only the keys that need attention: missing in some
language, empty, referenced but never declared (scenario `key=`, C#
`Translate`/`Tr`, JSON text fields, `"@key"` in collections, `.tscn` texts),
declared twice in one mod, plus collection texts without a `locale` entry.
It also checks the raw bytes of each locale file: valid UTF-8 (no UTF-16,
cp1251 bytes or U+FFFD), no control characters, no mojibake, BOM and line
endings.

```bash
godot --path . -- --locale-atlas --locale-out=res://tests/artifacts/locale_atlas
```

The report (`report.md`, `report.json`) names the file and line to add for
every gap. A `select ""` with no prompt is written without `key=`.

## Supported languages

The set of languages is fixed in code (`LocaleLoader.LangToLocale`) and the
settings dropdown lists exactly these three:

| Settings language | Locale code |
|---|---|
| Russian | `ru-ru` |
| English | `en` |
| Chinese | `zh` |

An unsupported settings value silently falls back to the `ru` locale.

### Loader API

Two static methods on `LocaleLoader` drive loading:

- `LoadCurrentLanguage()` — reads the language from the saved settings, maps it
  through `LangToLocale` (falling back to `ru`), and delegates to `LoadLocale`
- `LoadLocale(string locale)` — merges the `.properties` files for that locale
  across **all** loaded mods into one Godot `Translation`, replaces any previous
  translation for that locale on `TranslationServer`, and sets it as the active
  locale

`LoadLocale` can be called directly to switch languages programmatically.

## Adding a new language

Adding a language requires a code change, not just new files:

1. Add a mapping to `LocaleLoader.LangToLocale` (e.g. `{ "de", "de" }`)
2. Add the option to the language dropdown in `SettingsMenu`
3. Create `locale/{locale}.properties` (or any `*_{locale}.properties`) with UI text translations
4. Create scenario translation files following the same naming convention

## Adding a new locale key

1. Add the key to all language `.properties` files
2. Reference it in scenarios or UI code using the exact key string
3. If referenced from a `.scenario` file, place it in `scenarios_{lang}.properties`
4. If referenced from C# code, use `GodotTranslationProvider.Instance.Get("key")` — it returns `null` for untranslated keys (or Godot's `TranslationServer`)

===== DOC docs/mods/map/index.md: Map and Travel Zones =====
The world map is a full-screen overlay where the player picks a destination and travels there. A map is a set of **zones**: interactive areas drawn over the map background, each pointing at a scene. Zone geometry lives in mod JSON data, while the open/hidden state is runtime state that scenarios change through dedicated DSL actions and the save system persists.

## Directory structure

Map data is read from every loaded mod, in mod load order:

```filestree
mods/<mod_name>/
└── map/
    └── map.json        # any *.json in this folder is loaded
```

The base mod ships one file:

```filestree
mods/cthangover_game/
└── map/
    └── map.json        # "home" and "town_entry" zones + two labels
```

## Zone data format

Files use the standard `{"Items": [...]}` envelope. All coordinates are normalised `[0.0 .. 1.0]` against the displayed map background, which keeps layouts resolution-independent:

```jsonc
{
  "Items": [
    {
      "ID": "home",                         // unique zone id (also the DSL reference)
      "points": [[0.58, 0.26], [0.61, 0.25]], // polygon highlight + click area
      "title": "",                          // localisation key for the name pill
      "scene": "home_outside",              // target scene, passed to SceneManager
      "description": "mapzone/home/info",   // optional localisation key for the card text
      "image": "",                          // asset id of the marker icon (empty = no marker)
      "image_pos": [0.6, 0.2],              // normalised marker position
      "label_pos": [0.59, 0.15],            // optional free-standing text label position
      "locked": false,                      // authoring default: closed for travel
      "hidden": false                       // authoring default: absent from the map
    }
  ]
}
```

| Field | Type | Description |
|---|---|---|
| `ID` | `string` | Unique zone identifier; the key used by DSL actions and the save |
| `points` | `[x, y][]` | Polygon vertices, normalised `[0..1]`; drawn as a two-tone hatch and used as the generous click target (fewer than 3 points = no polygon) |
| `title` | `string` | Localisation key resolved via `TranslationServer.Translate` |
| `scene` | `string` | Logical scene name for the transition; empty or unknown scenes keep the zone closed (dimmed marker, blocked button) |
| `description` | `string` | Optional localisation key for the location card flavour text |
| `image` | `string` | Marker icon asset id resolved through the mod texture system |
| `image_pos` | `[x, y]` | Normalised marker position |
| `label_pos` | `[x, y]` | Optional position of a non-interactive text label |
| `locked` | `bool` | Authoring default for the closed state (`false` = enterable) |
| `hidden` | `bool` | Authoring default for visibility (`false` = drawn) |

### Zone kinds

- **Location** — has an `image` icon and a `scene`: clickable marker, polygon, location card, transition button.
- **Label (inscription)** — has `label_pos` but no `image`: a plain text caption naming a region. It is not clickable, does not open the card and is excluded from the zone counter.
- **Hidden zone** — `"hidden": true` or hidden at runtime: completely absent (no polygon, marker, label or counter entry) until revealed.

## Opening the map

The map is declared like any mod panel — in the manifest `ui` array:

```jsonc
{
  "id": "map",
  "kind": "panel",
  "slot": "ModBeforeUIPanel",
  "scene_path": "scenes/ui/map_widget.tscn",
  "button": { "icon": "map", "locale": "ui/map/title", "index": 10 },
  "priority": 10
}
```

`UiBuilder` instantiates the scene and hands a toolbar button to `ToolBox` (the HUD toolbar); pressing it toggles the panel through `UiRegistry`. The widget closes on `Escape`, on the header ✕ button, and automatically after a successful transition.

## Runtime state

Zone state has two layers:

- **Authoring defaults** — `locked` / `hidden` flags from the JSON above.
- **Runtime overrides** — sparse dictionaries owned by the `MapZoneService` singleton (`Core/UI/Tool/MapZoneService.cs`), changed via `Open`/`Close`/`Show`/`Hide`/`ResetZone`.

The effective state is the override when present, otherwise the authoring default. Every change publishes a `MapZoneChangedEvent`, so an open map updates live when a scenario changes a zone. Extra rule shared by the game and the editor (`MapZoneRules`): a zone whose `scene` is empty or unregistered is always treated as locked, so authors can sketch future regions safely. The zone matching the active scene is marked *you are here* and cannot be entered again.

## Scenario DSL actions

Four actions change zone state from a scenario. Each takes the required `zone_id` parameter; unknown IDs are ignored with a warning, so scripts can reference zones added by other mods.

| Action | Parameters | Effect |
|---|---|---|
| `action map.open_zone` | `zone_id=Id` | Opens the zone for travel |
| `action map.close_zone` | `zone_id=Id` | Closes the zone for travel |
| `action map.show_zone` | `zone_id=Id` | Reveals a hidden zone on the map |
| `action map.hide_zone` | `zone_id=Id` | Removes the zone from the map until shown again |

```scenario
action map.open_zone zone_id=temple
action map.show_zone zone_id=temple_label
```

Typical authoring pattern: ship zones with `"locked": true` / `"hidden": true` and open them from story events; the overrides survive scene changes and play sessions.

## Saving

Only the sparse overrides are saved — not the effective state. `RuntimeSerializer` stores them as a `MapZones` blob in the core `__core__` save slot (`open` and `visible` dictionaries keyed by zone ID, see `MapZoneStateData`), and `MapZoneService.ImportState` restores them on load. Storing deltas means a mod update that changes its authoring defaults is respected for every zone the player never touched. Starting a new game resets all overrides (`AppServices` resettable `map_zones`).

Persistence is covered by an integration test that runs the real `SaveService` save → reset → load cycle (`tests/Cthangover.Tests.Integration/Map/MapZonePersistenceTestSetup.cs`).

## Map editor

The `editor_tools` mod ships a visual map editor (`mods/editor_tools/src/MapEditor/`, tool id `map_editor`). It edits the same zone JSON: a canvas with draggable polygon, marker and label handles, per-zone fields, load/save of `map/*.json` into the selected mod, and a game-like preview that renders the real `map_widget.tscn` with the unsaved data. The editor and the game share one style implementation (`MapZoneStyle`), so both views render zones identically; after saving, the running map reloads without a game restart.

See Creating a Custom Tool for how tools like this are registered.

## Code reference

| Component | Location |
|---|---|
| `MapWidget` | `Core/UI/Tool/MapWidget.cs` — overlay behaviour, markers, location card |
| `MapZonesLayer` | `Core/UI/Tool/MapZonesLayer.cs` — polygon hit-testing and painting |
| `MapMarker` | `Core/UI/Tool/MapMarker.cs` — marker emblem (`scenes/ui/map_marker.tscn`) |
| `MapZone` | `Core/UI/Tool/MapZone.cs` — JSON zone DTO |
| `MapZoneService` | `Core/UI/Tool/MapZoneService.cs` — zone list + runtime overrides singleton |
| `IMapZoneService` | `Core/Actions/IMapZoneService.cs` — scenario-facing facade (`ctx.Map`) |
| `MapZoneRules` / `MapZoneStyle` | `Core/UI/Tool/` — shared enterability rule and zone visuals |
| `Map*ZoneAction` | `Core/Actions/Atomic/` — the four DSL commands |

===== DOC docs/mods/metadata/index.md: Scene Event Metadata =====
The `[SceneEvent]` attribute allows mod C# code to hook into scene lifecycle events declaratively, without needing manifest subscriptions.

## `[SceneEvent]` attribute

Place the attribute on a **class** in a mod assembly to register it for automatic execution. Only classes deriving from `ExecutableEvent` are registered (`Core/Scenes/SceneEventRegistry.cs` skips any other type):

```csharp
using Cthangover.Core.Scenes;
using Cthangover.Core.UI.Executable;

[SceneEvent("home_kitchen", Priority = 5)]
public class KitchenInitEvent : ExecutableEvent
{
    // Runs when home_kitchen scene loads, ordered within the
    // scene's event chain by its Priority / After metadata
}

[SceneEvent("town_entry", After = "other_event")]
public class TownEnterEvent : ExecutableEvent
{
    // Runs after the event whose ID is "other_event"
}
```

There is no method-based registration and no `SceneContext` parameter — the attribute cannot be applied to methods (`AttributeUsage(AttributeTargets.Class, AllowMultiple = false)`).

## Attribute parameters

| Parameter | Type | Description |
|---|---|---|
| `SceneName` | string | *(positional, required)* Target scene name |
| `Priority` | int | Execution order (lower = earlier). Default 0 |
| `After` | string | Name of another handler this should run after |
| `Condition` | string | Condition expression — handler only runs if expression is true |

## Registration

`SceneEventRegistry` uses reflection at assembly load time to discover all **classes** decorated with `[SceneEvent]`. Only types deriving from `ExecutableEvent` register; decorated classes without that base type are silently skipped. No manual registration is needed — the attribute itself serves as registration.

## Execution order

`[SceneEvent]` classes are not a separate phase: `SceneManager.ComposeEvents` composes them into the **same event chain** as `.scenario` definitions. The composition runs synchronously inside `SceneManager.SwitchScene` — it is **not** a bus subscriber — and the composed chain is enqueued right after the `scene_entered` lifecycle event is published. Engine subscribers of that event run at their own fixed priorities (background 0, ambient audio 5, music type 10, UI host 30), which are independent of scenario ordering. So the order on scene enter is:

1. `scene_entered` bus subscribers apply scene defaults, audio, and UI host changes
2. The composed chain executes: scenario + `[SceneEvent]` events interleaved and sorted by their own `priority` / `After` metadata

On scene exit, `scene_exited` is published on the bus; there are no per-scene exit hooks beyond its subscribers.

## vs manifest `ui` entries

| Feature | `[SceneEvent]` attribute | Manifest `ui` entry |
|---|---|---|
| Registration | Attribute on class (must derive from `ExecutableEvent`) | JSON entry in `manifest.json` |
| Purpose | Game logic running in the scene's event chain | UI panels/overlays anchored to shell slots |
| Scene binding | `SceneName` positional parameter (required) | Optional `scene` field — when set, the element is only available in that scene |
| Condition support | Yes (`Condition` param) | No |
| Priority ordering | Yes | Yes (order inside the anchor slot) |
| Dependencies (`After`) | Yes | No |

Use `[SceneEvent]` for game-logic hooks. Use manifest `ui` entries when you need to anchor a panel or overlay to a shell slot.

## Manifest `conflicts_with` — declarative only

The `conflicts_with` manifest field is **declarative only**: it is parsed (`Core/Mods/ModManifest.cs`) and exported to catalog entries, but the runtime does **not** enforce conflicts — no mods are disabled or blocked at load time. Treat it as informational metadata describing mutually exclusive mod pairs for catalogs and tooling.

## Naming your mod

The `id` tells other modders what role the mod plays, by a fixed suffix or
prefix, in lowercase `snake_case`. The folder name, `id`, the repository
(`mod_<id>`) and the locale keys `mod_<id>_name`/`mod_<id>_desc` match.

| Role | Pattern | Examples |
|---|---|---|
| Engine subsystem other mods build on | `<subject>_base` | `dialog_base`, `ui_base`, `shell2d_base` |
| Mechanic, optional for a game | plain noun or `<variant>_<mechanic>` | `cooking`, `card_battle` |
| The game itself: scenes, story, entry point | `<game>_game` | `cthangover_game` |
| Further content of one game | `<game>_<part>` | `cthangover_murakami`, `cthangover_party` |
| Reusable content any game may take | `<theme>_pack` | `town_pack` |
| Template to copy | `<what>_template` | the collection `novel_template` (its own mod: `my_novel_game`) |
| Developer tool | `<subject>_<tool>` | `editor_tools`, `resource_atlas` |
| Test fixture | `test_<what>` | `test_defaults_and_patches` |

Never use `core` in an id and never `base` as a prefix. The game a player
picks is not a mod but a collection package that lists
these mods.

## `manifest.json` field reference

Every mod's `manifest.json` is deserialized into `ModManifest` (`Core/Mods/ModManifest.cs`). All fields are optional:

| Field | Type | Description |
|---|---|---|
| `id` | string | Canonical mod identifier. Used for deduplication (when two mods declare the same `id`, the higher `version` wins) and in other mods' `depends`. Falls back to the filesystem entry name when absent |
| `name` | string | Human-readable mod name (shown in UI) |
| `version` | string | Semver mod version (e.g. `"1.0.0"`). Used by the save system to detect downgrades and trigger migrations |
| `author` | string | Author credit string |
| `description` | string | Short description of what the mod does |
| `category` | string | Optional catalog category, or a comma-separated combination for mods that belong to several groups (e.g. `"library, gameplay"`). Slugs: `story`, `characters`, `gameplay`, `visual`, `audio`, `ui`, `locale`, `library`, `engine`, `overhaul`, `tools`, `editor`, `debug`, or `other` (default). Parsed case-insensitively into the `[Flags]` `CategoryType`; all selected categories are pre-checked in the Catalog Export dialog (`CatalogExportWindow`) |
| `sources` | string[] | Glob-style paths to C# source files (e.g. `"src/"`, `"scripts/*.cs"`). Collected and compiled via Roslyn by `ModCompiler` |
| `gd_sources` | string[] | Glob-style paths to GDScript files (e.g. `"scripts/*.gd"`, `"gd/**/*.gd"`). `ModGDScriptLoader` collects them, compiles via Godot's GDScript runtime, and registers them as scenario actions or mod initializers |
| `depends` | string[] | Versioned dependencies in format `modId[op][version][?]` — see below. Parsed into `ModDependency` entries via `ModDependencyListConverter`; affects topological compile order and version validation |
| `depends_native` | string[] | Glob patterns for native shared libraries handled by `ModNativeLoader` — see below |
| `conflicts_with` | string[] | Mutually exclusive mod IDs. Declarative only — see section above |
| `ui` | array | GUI declarations. Each entry is a toolbar-toggled panel (`"kind": "panel"`) or a code-driven overlay (`"kind": "overlay"`) anchored to a named shell slot (`slot`), with an optional `scene` field scoping it to one scene |
| `main_menu` | array | Buttons for the title screen; each entry opens a fullscreen mod scene |

### `depends` — version syntax

Each `depends` entry is a string `[modId][op][version][?]`, parsed by `ModDependency.Parse` (`Core/Mods/ModDependency.cs`):

```jsonc
{
    "depends": [
        "cthangover_game",           // bare id — any version
        "cthangover_game>=1",        // core 1 or newer
        "my_lib^1.2.0",   // version starting with segments 1.2.0
        "extra_icons<2",
        "soft_dep?"       // trailing ? — optional dependency
    ]
}
```

| Operator | Meaning |
|---|---|
| *(none)* | Any version — bare `"modId"` |
| `>=` / `<=` / `>` / `<` | Per-segment comparison against the dependency's actual version |
| `=` | Exact match (segment-wise) |
| `^` | Prefix match — required segments must equal the leading segments of the actual version |

- A trailing `?` marks an **optional** dependency: if the mod is missing, that is not an error; if present, its version is still checked.
- Comparison goes through `VersionHelper.CompareSegments` (`Core/Utils/VersionHelper.cs`): both version strings are split on `.`, both segment arrays are padded with `"0"` to equal length, and each segment compares numerically if both parse as int, otherwise via ordinal string comparison. Segments may contain letters (`1a`, `alpha-3`, `1b.2.0`).
- Entries are read from JSON by `ModDependencyListConverter`: malformed strings are logged as errors, kept with `IsValid = false`, and reported later during `ValidateDependencies`.
- Valid dependency IDs also drive load order: `ModRegistry.GetOrderedModIds` topologically sorts so dependencies always precede their dependents.

### `depends_native` — native libraries

`depends_native` lists glob patterns (e.g. `"bin/*.dll"`, `"native/*.so"`, `"lib/*.dylib"`) for native shared libraries shipped inside the mod. `ModNativeLoader.LoadNativeDlls` (`Core/Mods/ModNativeLoader.cs`), called from `ModRegistry.Initialize()` before managed mod code runs:

1. Collects platform-appropriate files (`.dll` on Windows, `.so` on Linux, `.dylib` on macOS)
2. Copies them to the cache directory (`user://mod_cache/native/`) so zip-based mods can serve native code
3. Registers a `DllImportResolver` so P/Invoke (`[DllImport]`) declarations resolve against the mod-provided native code

If multiple mods provide the same library name, the first one wins; duplicates are logged as warnings. On reload, `ModRegistry.Reload()` calls `ModNativeLoader.FreeAll()` to release all native handles so the files can be overwritten on the next load cycle.

### Precompiled DLLs (`dll/` directory)

A mod may ship precompiled .NET assemblies in a `dll/` directory. During `ModRegistry.Initialize()`, `ModAssemblyLoader.LoadPrecompiledDlls` (`Core/Mods/ModAssemblyLoader.cs`) scans that directory recursively, copies each `.dll` to the assembly cache (`{modId}_dll_{fileName}`), loads it, and runs the standard `RegisterAssembly` fan-out (the same registries listed on the C# Source Code page).

This runs **before** Roslyn compilation of `sources`, so source-based mods can reference types from precompiled API assemblies. Each loaded precompiled assembly also gets a `DllImportResolver` installed (`ModNativeLoader.InstallResolverOnAssembly`) for its own P/Invoke calls.

===== DOC docs/mods/musics/index.md: Music & Playlists =====
Background music tracks (`.ogg`) organized into playlists with ambient/combat categorization. Playlist selection happens per scene: the engine picks the playlist whose `Scene` matches the current scene name and switches between its categories based on game state (exploration vs battle).

## Directory structure

```filestree
music/
├── track_name_1.ogg
├── track_name_2.ogg
└── playlists/
    ├── default.json
    ├── battle.json
    └── town_entry.json
```

## File format

- `.ogg` — Vorbis-compressed audio, any bitrate
- Files are referenced by name (without extension) in playlists

## Real example — `cthangover_game/music/playlists/default.json`

```jsonc
{
  "Scene": "default",               // Scene name this playlist belongs to ("default" is the fallback)
  "Musics": [
    {
      "MusicType": "Ambient",       // Category: music for exploration/non-combat states
      "MusicNames": [               // Track filenames without .ogg extension
        "Abyssal Void",
        "Airborne Melody",
        "Binary Prelude",
        "Cinders of Dawn",
        "Melancholic Journey",
        "Nebula Pulse Echoes",
        "Quantum Archive",
        "Twilight Interlude"
      ]
    },
    {
      "MusicType": "Combat",        // Category: music switched to during battles
      "MusicNames": [
        "Aura of Decay",
        "Circuit Dreams II",
        "Crimson Echoes",
        "Crimson Horizon",
        "Glass Symphony",
        "Hex Doom Cart",
        "Iron Resonance",
        "Iron Resonance II",
        "Luminous Twilight",
        "Rusty Frequencies",
        "Skull Resonance",
        "Venom's Embrace",
        "Empty Save Room"
      ]
    }
  ]
}
```

8 ambient tracks for exploration, 13 combat tracks for battles — 21 total.

## Playlist JSON schema

```jsonc
{
  "Scene": "scene_name",              // Scene name this playlist targets ("default" = fallback for any scene)
  "Musics": [
    {
      "MusicType": "Force | Combat | Ambient",  // Category: Ambient (explore), Combat (battle), Force (transient)
      "MusicNames": ["track_1", "track_2", "..."]   // Track filenames without .ogg extension
    }
  ]
}
```

| Field | Required | Description |
|---|---|---|
| `Scene` | Yes | Scene name the playlist applies to (`"default"` is used when no playlist matches the scene) |
| `MusicType` | Yes | Category: `Ambient`, `Combat`, `Force`. `Force` is transient — normalised to `Ambient` |
| `MusicNames` | Yes | List of track filenames (no extension, no path) |

These are the only supported fields. There is no shuffle flag — a random track
is picked each time playback starts/advances. Fading is hard-coded in the
engine (~1s delay, then a volume ramp) and cannot be configured per playlist.

## Usage in scenarios

The `music`, `music_play`, and `music_pause` commands control music playback:

```scenario
# Play a single track by id (resolved via MusicFactory, as Ambient type)
music tavern_theme

text "A bard strums a melancholy tune..."

# Pause everything
music_pause

# Resume
music_play
```

Note that `music <id>` plays one track — it does not switch playlists.
Playlist selection is driven by scenes, not by scenario commands.

## Scene default ambient

Scene definitions can specify a `defaultAmbient` — but this is an **ambient
sound loop**, not a playlist. It is played via `AudioService.PlayAmbient(id)`
through the sound system (`SoundFactory`, group `"sounds"`), separate from the
music playlist system:

```jsonc
{
  "name": "town_entry",                                 // Scene ID
  "defaultBackground": "town/entry",                    // Background image path
  "defaultAmbient": "locations/wind",                   // Ambient sound id from sounds/
  "defaultScenario": "scenarios/town/town_entry_default.scenario"  // Default scenario on enter
}
```

Music playlists are matched to scenes automatically by scene name (see below),
not via `defaultAmbient`.

## Adding a new track

1. Place the `.ogg` file in `music/`
2. Add its name (without `.ogg`) to the desired playlist's `MusicNames` array
3. Restart the game — tracks are loaded at startup

## How playlists are selected

- `PlaylistFactory.CreatePlaylist(sceneName)` collects every `music/playlists/*.json`
  across all mods and uses those whose `Scene` matches the current scene name;
  if none match, it falls back to `Scene: "default"`
- When several mods define the same `MusicType` for a matched scene, the last
  loaded mod **replaces** that entire category's track list (last one wins)
- The music type is chosen per scene through the scene-route registry
  (`SceneRouteRegistry`): a mod registers a logical scene name together with a
  `MusicType` — `turn_battle_base` registers `Battle` → `Combat` — and any
  scene without a registered route plays `Ambient` tracks

## `MusicPlayerBehaviour` public API

`Cthangover.Core.Audio.MusicPlayerBehaviour` (`Core/Audio/MusicPlayerBehaviour.cs`)
is the node that drives playlist auto-advance and scene-aware switching. Its
public surface:

| Member | Behaviour |
|---|---|
| `IsCanAutoPlay` | Top-level toggle for the auto-advance state machine; when `false` no tracks are picked and scene switches don't trigger playback |
| `EnabledAutoPlay()` | Re-enables auto-play and resumes the last saved track |
| `DisabledAutoPlay()` | Saves the current playback position, disables auto-play, stops playback |
| `UpdateMusicType(scene)` | Scene-transition entry point (`GodotSceneType` or string overload); orchestrates the Combat ↔ Ambient handoff |
| `PlayMusic(name[, stream][, isLooped])` | Plays a track by name (stream resolved via `MusicFactory`) or with an explicit `AudioStream`; the `isLooped` parameter is accepted but currently unused |
| `PauseMusic()` / `StopMusic()` | Pause in-place via `StreamPaused` vs. full stop (stream reference kept for restart) |
| `FadeMusic()` | Hard-coded fade-out: 1s delay, then a 6-second volume ramp to silence, then stop and reset bus volume |
| `NextSound()` | Picks a random track from the current playlist's category bucket, avoiding an immediate repeat when more than one track exists |

When combat ends, the ambient track that was interrupted is resumed from its
saved position — if that position is greater than 0.5s,
`OggPacketParser.CreateTrimmedStream` builds a truncated OGG stream starting at
the saved time instead of seeking.

## Creating a new playlist

1. Create `music/playlists/my_scene.json`
2. Set `Scene` to the exact scene name the playlist targets
3. Define `MusicType` and `MusicNames` entries — the engine picks the playlist up automatically when that scene loads

===== DOC docs/mods/musics/playlists/index.md: Playlists — JSON Reference =====
Detailed schema for music playlist configuration JSON files.

## Full schema

```jsonc
{
  "Scene": "scene_name",                // Scene name this playlist targets ("default" = fallback for any scene)
  "Musics": [
    {
      "MusicType": "Force | Combat | Ambient",  // Category: Ambient (explore), Combat (battle), Force (transient)
      "MusicNames": ["track1", "track2", "..."]   // Track filenames without .ogg extension
    }
  ]
}
```

These are the only supported fields. There is no shuffle flag — a random track
is picked each time playback starts or advances. Fading is hard-coded in the
engine (~1s delay, then a volume ramp) and cannot be configured per playlist.

## Field details

| Field | Type | Description |
|---|---|---|
| `Scene` | string | Scene name this playlist applies to (`"default"` is used when no playlist matches the loaded scene) |
| `MusicType` | string | Category: `Ambient` (exploration), `Combat` (battle), `Force` (transient, normalised to `Ambient`). A scene named `Battle` switches playback to `Combat`, otherwise `Ambient` plays |
| `MusicNames` | string[] | Track filenames without extension. Must match `.ogg` files in `music/` |

## Real example — default playlist

```jsonc
{
  "Scene": "default",               // Scene name; "default" is the fallback playlist
  "Musics": [
    {
      "MusicType": "Ambient",       // Exploration music
      "MusicNames": [
        "Abyssal Void", "Airborne Melody", "Binary Prelude",
        "Cinders of Dawn", "Melancholic Journey",
        "Nebula Pulse Echoes", "Quantum Archive", "Twilight Interlude"
      ]
    },
    {
      "MusicType": "Combat",        // Battle music — auto-switched by engine
      "MusicNames": [
        "Aura of Decay", "Circuit Dreams II", "Crimson Echoes",
        "Crimson Horizon", "Glass Symphony", "Hex Doom Cart",
        "Iron Resonance", "Iron Resonance II", "Luminous Twilight",
        "Rusty Frequencies", "Skull Resonance", "Venom's Embrace",
        "Empty Save Room"
      ]
    }
  ]
}
```

## Multiple playlists example

A single playlist can cover different states by defining multiple `MusicType` buckets:

```jsonc
// music/playlists/tavern.json — applies to the scene named "tavern"
{
  "Scene": "tavern",
  "Musics": [
    { "MusicType": "Ambient", "MusicNames": ["Tavern_Day", "Tavern_Night"] },
    { "MusicType": "Combat", "MusicNames": ["Tavern_Fight_01", "Tavern_Fight_02"] }
  ]
}
```

## Scene integration

Playlist selection happens per scene, automatically. When a scene loads,
`PlaylistFactory.CreatePlaylist(sceneName)` uses the playlist whose `Scene`
matches the scene name (falling back to `"default"`):

```jsonc
{
  "name": "town_tavern"    // Scene ID — playlists with "Scene": "town_tavern" apply here
}
```

Note: `defaultAmbient` in a scene JSON is unrelated to playlists — it plays an
ambient sound loop via the sound system (`AudioService.PlayAmbient`), not music.

The scenario command `music <id>` also does not switch playlists; it plays a
single track by id.

## How the engine switches playlists

1. Scene loads → `PlaylistFactory.CreatePlaylist(sceneName)` picks the playlist whose `Scene` matches (fallback `"default"`) → starts playing `Ambient` tracks
2. The music type comes from the scene-route registry (`SceneRouteRegistry`): `turn_battle_base` registers the `Battle` route with `MusicType` `Combat` → tracks come from that playlist's `Combat` bucket; scenes without a route (and the return to them) play `Ambient`
3. Scene changes → the new scene's playlist takes over, with the hard-coded fade
4. Multi-mod note: if several mods define playlists for the same scene, the last loaded mod replaces each `MusicType` bucket entirely (last one wins)

Playback always picks a random track from the active category bucket — there is
no ordered or shuffle-flagged mode.

Music types: `Force`, `Combat`, `Ambient`. Mixer buses (`MixerType`): `Sounds`,
`Musics`, `Ambient` — music plays through the dedicated `Musics` bus.

===== DOC docs/mods/patches/arrays-and-identity.md: Array Operations & Identity Keys =====
## Array Operations

When a patch modifies a JSON **array** property (e.g. `Skills`, `Perks`, `Tags`), the suffix on the key determines how the arrays are merged:

| Key suffix | Behaviour |
|---|---|
| *(no suffix)* | **Overwrite** — replaces the entire array. Same semantics as scalar properties. |
| `$add` | **Add** — appends incoming elements to the existing array. If the property doesn't exist yet, creates it. |
| `$remove` | **Remove** — removes incoming elements from the existing array. If the property doesn't exist, no-op. |
| `$set` | **Overwrite** — explicit form, same as bare key. |

### Examples

**Base character definition** (the entity object inside a `Kind: "character"` file):
```jsonc
// Base entity definition (Items entry of a character file)
{ "Id": "Marao", "Skills": ["slash", "block"] }  // Id is the entity identifier; Skills is the array to patch
```

**Add elements** — a cooking mod extends skills:
```jsonc
// patches/character.json
// $add appends elements to the existing array (creates if missing)
{ "Items": [{ "Id": "Marao", "Skills$add": ["cook"] }] }
```
Result: `["slash", "block", "cook"]`

**Remove elements** — a balance patch removes a skill:
```jsonc
// $remove deletes matching elements from the array (no-op if missing)
{ "Items": [{ "Id": "Marao", "Skills$remove": ["block"] }] }
```
Result: `["slash", "cook"]`

**Replace entirely** — a weapon overhaul resets skills:
```jsonc
// $set replaces the entire array (explicit form, same as bare key)
{ "Items": [{ "Id": "Marao", "Skills$set": ["sword", "shield_bash"] }] }
```
Result: `["sword", "shield_bash"]`

**Overwrite (bare key)** — equivalent to `$set`:
```jsonc
// Bare key overwrites the entire array (same as $set); previous values discarded
{ "Items": [{ "Id": "Marao", "Skills": ["slash", "block"] }] }
```
Result: `["slash", "block"]` (previous values are discarded)

### Multi-layer example

All three operations can be combined across mod layers. Processing order follows the standard property pipeline (entity → wildcard `*` → specific `Id`):

```jsonc
// Entity base: ["slash", "block"]

// Wildcard patch: $add appends "dodge" to ALL entities first
{ "Items": [{ "Id": "*", "Skills$add": ["dodge"] }] }
// → ["slash", "block", "dodge"]

// Specific patch: $remove deletes "slash" from Marao only
{ "Items": [{ "Id": "Marao", "Skills$remove": ["slash"] }] }
// → ["block", "dodge"]
```

**No deduplication** is performed — if the same element is added twice, it appears twice.

### Universality

This system applies to **any** array property on any entity type — characters, items, and any mod-defined extras. There is no special-casing for specific property names.

```jsonc
// Works for any array property — no special-casing needed
{ "Items": [{ "Id": "sword/iron", "CraftingMaterials$add": ["steel_ingot"] }] }   // Specific item patch
{ "Items": [{ "Id": "*", "Tags$add": ["undead"] }] }                                // Wildcard: tags all entities
```

Scalar properties (int, float, bool, string) still use **last-wins** semantics — the suffix convention only affects JSON array values.

---

## Object Arrays with Identity Keys

> ⚠ **IMPORTANT CONVENTION: the identity field MUST be named `Id` (or end in `Id`, like `ItemId`, `QuestId`, `ActionId`).**
>
> For mod-defined (extra-property) arrays this field is **auto-detected** — no registration, no config, no C# code needed.
> For **typed** C# array properties there is a second path: if the element type
> implements `IMergeable`, its `MergeKeyProperty` value is used **first**, taking
> priority over the naming convention (see below).

For arrays of **objects** (e.g. `Loot`, `Rewards`, `Drops`), the system matches elements by an **identity field** instead of raw JSON text. This enables **per-field merge**: a patch can update a single property of an existing object without repeating all its fields.

### How identity detection works

Resolution order per property:

1. **Interface first** — if the entity type is known and the array property's
   element type implements `IMergeable` (`Core/Mods/Patches/IMergeable.cs`,
   a single `MergeKeyProperty` string property), the key comes from
   `MergeKeyProperty`. The map is built by `JsonPatchMerger.BuildMergeKeys<T>()`.
   Example: `LootEntry` (`Core/Characters/LootEntry.cs`) implements
   `IMergeable` with `ItemId` as its key, so the typed `Loot` property always
   merges by `ItemId`.
2. **Convention fallback** — when no interface-provided key exists, the key is
   auto-detected from the array JSON itself (table below).

| Array element type | Detection | Identity field |
|---|---|---|
| `{"Id": "gold", ...}` | First field named `Id` → detected | `Id` |
| `{"ItemId": "sword", ...}` | No `Id`, but field ending in `Id` → detected | `ItemId` |
| `{"QuestId": "q1", ...}` | Same logic | `QuestId` |
| `{"Name": "test"}` | Nothing matches → no identity | Raw text compare |
| `"string"` or `42` | Not an object → no identity | Raw text compare |

**The system prioritises exact `"Id"` over suffix matches.** If your object has both `Id` and `ItemId`, `Id` wins.

### Per-field merge example

```jsonc
// Base entity: object array with ItemId as identity key
{ "Loot": [
    {"ItemId": "food/wolf_meat", "MinCount": 1, "MaxCount": 1, "Probability": 100},    // Matched by ItemId
    {"ItemId": "resource/branches", "MinCount": 1, "MaxCount": 1, "Probability": 100}  // Matched by ItemId
]}

// Balance patch: uses $add/$remove on the object array
{ "Items": [{ "Id": "wolf_1",
    "Loot$add": [{"ItemId": "food/ration", "Probability": 50}],        // Adds new entry
    "Loot$remove": [{"ItemId": "resource/branches"}]                    // Removes by identity match
}]}

// Result:
// → food/wolf_meat: unchanged
// → resource/branches: removed (matched by ItemId)
// → food/ration: added as new entry
```

### Per-field property overwrite

When `$add` finds an existing element with the same identity value, the patch **overwrites only the specified properties**, preserving unmentioned fields from the base:

```jsonc
// Base: {"ItemId": "food/bread", "MinCount": 1, "MaxCount": 2, "Probability": 50}
// Patch: {"Loot$add": [{"ItemId": "food/bread", "Probability": 80}]}   // Matches by ItemId, merges only Probability
// Result: {"ItemId": "food/bread", "MinCount": 1, "MaxCount": 2, "Probability": 80}
//         (Probability overridden, MinCount/MaxCount preserved from base — per-field merge)
```

### Removing with identity key

The `$remove` operation only needs the identity field — other fields in the remove object are ignored:

```jsonc
// $remove only needs the identity field (ItemId); other fields are ignored
{ "Items": [{ "Id": "wolf_1", "Loot$remove": [{"ItemId": "food/wolf_meat"}] }] }
```

### Mod-defined arrays — no config needed

Any mod can introduce a new array of objects in a character or item JSON, and patches immediately work with per-field merge — as long as objects have an `Id` or `*Id` field:

```jsonc
// Mod A defines a quest with rewards (identity key: Id)
{
  "Id": "daily_hunt",
  "Name": "Daily Hunt",
  "Rewards": [
    {"Id": "gold", "Count": 100},     // Identity field "Id" detected automatically
    {"Id": "exp", "Count": 50}
  ]
}

// Mod B patches the rewards without knowing Mod A's internals:
{ "Items": [{ "Id": "daily_hunt",
    "Rewards$add": [{"Id": "gold", "Count": 200}],   // Matched by Id → overwrites Count, preserves Id
    "Rewards$remove": [{"Id": "exp"}]                 // Matched by Id → removes entire exp entry
}]}

// Result Rewards: [{"Id": "gold", "Count": 200}]
```

**No C# code, no interface, no registration** is needed for mod-defined
extra-property arrays — the `Id` / `*Id` convention is all that's required.
The `IMergeable` interface is an optional, higher-priority path for typed C#
array properties (see the resolution order above); it does not need to be
implemented for convention-based detection to work.

===== DOC docs/mods/patches/index.md: Patches =====
Patches let one mod add or override properties of entities defined in another mod — without rewriting their JSON files entirely.

- **`"Id": "EntityName"`** — apply only to the entity with this ID.
- **`"Id": "*"`** — wildcard: applies to **all** entities of this type, serving as a fallback value. An entity's own JSON-defined properties always take precedence over wildcard values.

Works for every entity kind the game defines — `character`, `item`, `action`, `skill`, `quest`, `recipe`, `status_effect` — keyed by kind, not by directory.

## Overview

Each mod can contain an optional `patches/` directory. Patch sources are
collected by `PatchResolver` (`Core/Mods/Resolvers/Impls/PatchResolver.cs`),
which scans **both** layouts per entity kind:

```filestree
mods/
└── my_mod/
    └── patches/
        ├── character.json          ← flat file: patches for characters
        ├── item.json               ← flat file: patches for items
        └── character/              ← directory form: any *.json inside is scanned too
            ├── player.json
            └── enemies.json
```

- Flat files: `patches/{kind}.json` (e.g. `patches/character.json`)
- Directory form: every `.json` inside `patches/{kind}/`

Both forms use the same format below; results from all mods are grouped by
target entity id. From C#, patches are collected via the static facade
`Patches.Collect(kind)`, which proxies through
`ModManager.Instance.GetResolver<PatchResolver>()` (results are cached per
kind until `ClearCache()`). The kind is the entity kind declared in the target
file's envelope (`"Kind": "character"`), so a character patch is never applied
to skills or actions.

## Format

Standard `{"Items": [...]}` wrapper. Each entry contains an `Id` and arbitrary properties to add:

```jsonc
{
  "Items": [
    {
      "Id": "Marao",                    // Id matching: applies only to entity with this exact Id
      "TestPatched": 42,                // Arbitrary property added via patch
      "TestDefault": 100                // Will be layered over wildcard values
    },
    {
      "Id": "*",                        // Wildcard: applies to ALL entities as a fallback
      "TestDefault": -20,               // Overridden by entity's own properties or specific patches
      "TestGlobalPatch": 1              // Global property applied to all entities
    }
  ]
}
```

## Property Resolution Order

When creating an entity, its properties are assembled in layers. Each subsequent layer overwrites keys from the previous ones:

| Layer | Source | Priority |
|---|---|---|
| 1 | Entity's own properties (from its JSON) | Base |
| 2 | Wildcard patches `"*"` (in mod load order) | Higher |
| 3 | Targeted patches by `Id` (in mod load order) | Highest |

## Patches from Different Mods

Patches **accumulate**: each mod adds its entries to the list for the target ID. When applied, they are iterated in load order, and a later mod overwrites keys of an earlier one.

Mod A:
```jsonc
// Wildcard patch adds Test and Strength to all characters
{ "Items": [{ "Id": "*", "Test": -20, "Strength": 10 }] }
```

Mod B (loaded after A):
```jsonc
// Mod B (loaded after A): overwrites Test but preserves Strength
{ "Items": [{ "Id": "*", "Test": 0 }] }
```

Result for all characters: `{ "Test": 0, "Strength": 10 }` — `Test` overridden, `Strength` preserved.

## Full Example

### Mod A — defines a character

```jsonc
// mods/mod_a/characters/player/hero.json
{
  "Id": "Hero",           // Unique entity identifier for patch targeting
  "Health": 100,          // Base properties defined by the entity itself
  "Attack": 20,
  "Strength": 15
}
```

### Mod B — adds patches

```jsonc
// mods/mod_b/patches/character.json
{
  "Items": [
    { "Id": "Hero", "Test": 50, "CritChance": 15 },                    // Specific patch: applies only to Hero
    { "Id": "*", "Test": -20, "CritChance": 5, "GlobalBuff": 10 }      // Wildcard: fallback for all other characters
  ]
}
```

### Result for character `Hero`

| Property | Value | Source |
|---|---|---|
| `Strength` | 15 | Entity properties |
| `Test` | **50** | Specific patch `Hero` overrode wildcard `-20` |
| `CritChance` | **15** | Specific patch `Hero` overrode wildcard `5` |
| `GlobalBuff` | **10** | Wildcard patch `*` |

### Result for `Wolf` (no specific patch)

| Property | Value | Source |
|---|---|---|
| `Test` | **-20** | Wildcard `*` |
| `CritChance` | **5** | Wildcard `*` |
| `GlobalBuff` | **10** | Wildcard `*` |

## Items

Everything works identically for items. `ItemInfo` accepts arbitrary JSON keys via `[JsonExtensionData]`, which end up in `Item.Properties` (mirroring characters via `CharacterAttributes.Properties`).

```jsonc
// patches/item.json
{
  "Items": [
    { "Id": "food/ration", "Durability": 99, "SpecialProp": true },  // Specific item patch by Id
    { "Id": "*", "Durability": 50, "GlobalItemProp": "patched" }      // Wildcard: fallback for all items
  ]
}
```

## Test fixture

`mods/test_defaults_and_patches/` is a test fixture (`"fixture": true` in its manifest): `--all-mods` and games
never load it, so its wildcard patches cannot leak into other tests. The `property_patches` integration test
loads it explicitly and checks the merged entities:

```bash
godot --path . --headless -- --with-mods=test_defaults_and_patches --itest=property_patches
```

```filestree
mods/test_defaults_and_patches/
├── manifest.json
├── src/PropertyPatchesTest.cs   ← itest driver
└── patches/
    ├── character.json    ← Maraо: TestPatched=42, TestDefault=100; *: TestDefault=-20, TestGlobalPatch=1
    └── item.json         ← food/ration: Durability=99; *: Durability=50, GlobalItemProp=patched
```

## Array Operations & Public Helpers

Besides scalar last-wins overwrites, patch keys can carry array operation
suffixes — `$add`, `$remove`, and `$set` (parsed by
`ArrayMergeHelper.ParseArrayOperation`). These enable appending, removing, and
explicitly replacing array elements, including per-field merging of object
arrays matched by identity keys. See
Array Operations & Identity Keys for the full
reference.

Mechanically, `JsonPatchMerger.Merge` applies all patches whose entry id is
`"*"` (the wildcard targeting every entity of the type) first, then the
patches matching the concrete entity id.

Public helpers on `JsonPatchMerger` (`Core/Mods/Patches/JsonPatchMerger.cs`)
are available to C# code:

| Member | Purpose |
|---|---|
| `DetectIdentityKey(JsonElement)` | Auto-detects the identity field of an object array from its JSON: `"Id"` first, then the first property ending in `Id`; returns `null` if none |
| `BuildMergeKeys<T>()` | Reflects over type `T` and returns a map of list-typed property → identity key for element types implementing `IMergeable` |

## Pages

- Array Operations & Identity Keys — $add/$remove/$set, per-field merge, identity key detection
- Scene Patches — patching `.tscn` scenes with inherited scenes and `metadata/_patch_*` operations

===== DOC docs/mods/patches/scenes.md: Scene Patches =====
Scene patches let one mod modify a `.tscn` scene shipped by another mod or by the game itself — without copying and rewriting the whole file. A patch is a regular Godot **inherited scene**: it instances the target and lists only the changes. Godot's inheritance format already expresses property overrides, added nodes, sibling order, groups and connections; the few operations it cannot express (deleting nodes, deleting inherited properties, bulk edits, resource cleanup) are encoded with reserved `metadata/_patch_*` keys in the same file.

This is a separate subsystem from entity patches: the target is a scene path, not an entity kind.

## Patch file

Patches live in a mod under `patches/scenes/`, recursively:

```filestree
mods/
└── my_mod/
    └── patches/
        └── scenes/
            └── menu/
                └── main_menu.tscn      ← patch for scenes/menu/main_menu.tscn
```

The target is declared in the file itself — an inherited-scene header plus a root node that instances the target:

```ini
[gd_scene format=3]

[ext_resource type="PackedScene" path="res://mods/my_mod/scenes/menu/main_menu.tscn" id="1_target"]

[node name="MainMenuScene" instance=ExtResource("1_target")]
```

All three spellings of the same scene resolve to one target: the resolver key (`scenes/menu/main_menu.tscn`), the native form (`res://scenes/menu/main_menu.tscn`) and the on-disk form of a mod scene (`res://mods/<mod>/scenes/...`).

## Layers

Patches are applied on top of the scene that won the override battle in `TscnSceneResolver`:

1. The scene's own file (later mods override earlier ones entirely).
2. Patch layers in **mod load order**.
3. Inside one mod, patch files apply in **path order** — use `00-`, `10-` prefixes when the order matters.

Every layer sees the result of the previous ones, so a later mod can override a value set by an earlier patch.

## Cross-mod base scene (shell2d_base + visual novel)

`shell2d_base` owns the 2D shell scene; content mods add their layers to it with a patch instead of shipping their own shell:

```filestree
mods/
├── shell2d_base/
│   └── scenes/ui/base_scene.tscn           ← BaseScene / Scene / ViewBox / TopView / slots / notifications
└── cthangover_game/
    └── patches/scenes/ui/base_scene.tscn   ← DialogCanvas on top of the shell
```

```ini
[gd_scene load_steps=3 format=3]

[ext_resource type="PackedScene" path="res://mods/shell2d_base/scenes/ui/base_scene.tscn" id="1_target"]
[ext_resource type="PackedScene" path="res://scenes/ui/dialog_canvas.tscn" id="2_dialog"]

[node name="BaseScene" instance=ExtResource("1_target")]

[node name="DialogCanvas" parent="Scene" index="2" instance=ExtResource("2_dialog")]
```

The base shell is a single flat scene, so `index="2"` inserts the dialog exactly between `ViewBox` and `TopView`, and later mods can override or delete shell nodes the same way. The shell contract content mods rely on: root `BaseScene` in group `main_event_chain`, `Scene/ViewBox`, `Scene/TopView`, slots `ModFirstPanel` / `ModBeforeUIPanel` / `ModLastPanel`, and `Lastground` inside the view box. Both the target path and the added resource paths may use the editor's `res://mods/<mod>/...` form — it is normalised to the mod-relative key at load time.

## Operations

| Operation | How to write it | Editor |
|---|---|---|
| Override a property | node without `type`, `key = value` | yes |
| Override the root | properties on the root `instance=` node | yes |
| Add a node / subtree | node with `type=` | yes |
| Add a scene instance | node with `instance=ExtResource(...)` | yes |
| Replace a script | `script = ExtResource(...)` on an override node | yes |
| Reorder siblings | `index="N"` in the node heading | yes |
| Replace groups | `groups=[...]` in the node heading | yes |
| Add/remove groups | `metadata/_patch_add_groups` / `metadata/_patch_remove_groups` | no |
| Rebind exported node paths | `node_paths=[...]` + `NodePath(...)` values | yes |
| Add a connection | `[connection ...]` | yes |
| Delete a node | `metadata/_patch_delete = true` on the override node | no |
| Delete nodes by mask | root: `metadata/_patch_delete_nodes = PackedStringArray("Panel/*Btn")` | no |
| Delete a property | `metadata/_patch_remove = "tooltip_text"` | no |
| Delete properties by mask | `metadata/_patch_remove = PackedStringArray("theme_override_*")` | no |
| Delete a connection | root: `metadata/_patch_remove_connections = PackedStringArray("from=Panel/Btn;signal=pressed")` | no |
| Add resources | normal `[ext_resource]` / `[sub_resource]` sections | yes |
| Delete a sub_resource | `metadata/_patch_delete = true` in its body | no |
| Delete an ext_resource | root: `metadata/_patch_remove_ext_resources = PackedStringArray("1_tex")` | no |
| Change the entry point | `__entry_point = "My.Namespace.EntryPoint"` on the root | no |
| Resource garbage collection | root: `metadata/_patch_gc = true` | no |

A patch adds nodes when the node heading has `type=`/`instance=`; a heading without them overrides the existing node at that path. An override for a path that does not exist is logged and skipped.

## Deleting things

```ini
[node name="TelegramLink" parent="."]
metadata/_patch_delete = true

[node name="LoadBtn" parent="MenuContainer"]
metadata/_patch_remove = PackedStringArray("theme_override_*")
```

- `_patch_delete` removes the node, its descendants, connections touching the subtree and `NodePath` bindings that point into it.
- The scene root can never be deleted — the layer is rejected with an error.
- Paths are relative to the root and never include the root name (`MenuContainer/LoadBtn`, not `MainMenuScene/MenuContainer/LoadBtn`).
- Masks use `*` (any run of characters, including `/`) and `?` (one character). `_patch_delete_nodes = PackedStringArray("*")` removes every descendant and keeps the root.
- Deleting a property from the scene means the builder no longer applies it, so the Godot default remains. Godot has no real "unset".

## Connections

Add a connection with a normal section:

```ini
[connection signal="pressed" from="MenuContainer/LoadBtn" to="." method="_on_load_pressed"]
```

Remove one with a selector on the root — `from`, `to`, `signal`, `method` and `flags` are supported, omitted fields are wildcards, and every matching connection is removed:

```ini
[node name="MainMenuScene" instance=ExtResource("1_target")]
metadata/_patch_remove_connections = PackedStringArray(
    "from=MenuContainer/LoadBtn;signal=pressed",
    "to=.;signal=quit"
)
```

At least `signal` and one of `from`/`to` are required so a typo cannot silently wipe every connection.

## Resources

Patch resources are merged into the target:

- the same `type`+`path` already exists → the existing resource is reused;
- the id is free → it is copied as is;
- the id is taken by a different resource → the new resource gets a `_pN` suffix and all references in the patch are rewritten automatically.

`_patch_gc = true` removes ext/sub_resources that nothing references any more. GC runs **once after all layers**, so a resource that a later mod still references survives.

## Editor workflow

Create the patch with **File → New Inherited Scene**, pick the target, make your changes and save it as `.tscn` under `patches/scenes/`. Property overrides, added nodes, sibling order, groups, scripts and connections are authored normally. The operations Godot does not support in inherited scenes (deleting nodes, deleting properties, masks) are written by hand as the metadata keys above — adding metadata to an inherited root persists on save.

## Debugging

Patch layers are invisible in the editor: when several mods patch the same scene, each sees only the base scene. Use `ScenePatchDebug.DumpEffective(scene, label)` (or the `TSCN` log category) to print the effective tree the builder receives. Invalid patches are logged with `TSCN` errors/warnings and skipped, so one broken patch cannot break scene loading.

## Limitations

- **Renaming nodes is not supported.** Other mods address nodes by path; a rename would silently move every override to a non-existent path. Delete and re-add instead.
- Binary `.scn`/`.escn` scenes cannot be patched.
- Connections with `to` other than the scene root are filtered by the runtime builder, for patches as well.
- Array values are replaced as a whole; element-wise `$add`/`$remove` operations are not supported.
- Patching a native scene makes it build through the runtime TSCN pipeline from then on; scenes without patches keep the engine loader.

===== DOC docs/mods/quests/index.md: Quests =====
Quest definitions with status-based progression and tag-based conditions that control which scenarios execute.

## JSON structure

```jsonc
{
  // Entity kind — routes the file to the quest collector
  "Kind": "quest",
  // All quests of the file are listed under Items
  "Items": [{
    // Unique quest identifier (PascalCase); used in scenario condition expressions
    "Id": "QuestId",
    // Locale key prefix for the quest's display name in the journal
    "Name": "quest/locale/prefix",
    // Maps integer status codes to locale keys for journal descriptions at each stage
    "StatusToDescription": {
      "0": "locale_key_for_status_0",
      "1": "locale_key_for_status_1",
      "2": "locale_key_for_status_2"
    }
  }]
}
```

## Field reference

| Field | Description |
|---|---|
| `Id` | Unique quest identifier (PascalCase). Used in scenario `condition:` expressions |
| `Name` | Locale key prefix for the quest's display name |
| `StatusToDescription` | Map of status integers to locale keys for the journal description at each stage |

## Real examples

### Awakening quest — `cthangover_game/quests/quests.json`

```jsonc
{
  "Kind": "quest",
  "Items": [{
    // Quest ID — checked in scenario condition expressions
    "Id": "AwakeningQuest",
    // Locale key prefix → "Awakening"
    "Name": "quest/awakening",
    // One journal entry per prologue chapter; the journal strikes through
    // past entries and highlights the current `quest.Data.Status`
    "StatusToDescription": {
      "0": "quest/awakening/0",
      "1": "quest/awakening/1",
      "2": "quest/awakening/2",
      "3": "quest/awakening/3",
      "4": "quest/awakening/4",
      "5": "quest/awakening/5"
    }
  }]
}
```

### Old House quest — `cthangover_game/quests/quests.json`

Opened when the hero reaches the villa; the house arc is not written yet, so
the quest deliberately has no `StatusToDescription` — the journal shows its
name with the "pages are still empty" placeholder:

```jsonc
{
  "Kind": "quest",
  "Items": [{
    "Id": "OldHouseQuest",
    "Name": "quest/old_house"
  }]
}
```

### Meeting Murakami quest — `murakami/quests/quests.json`

```jsonc
{
  "Kind": "quest",
  "Items": [{
    // Quest ID — used in scenario conditions like MeetingMurakamiQuest.hasTag(...)
    "Id": "MeetingMurakamiQuest",
    // Locale key prefix → "The Girl at the Edge of Town"
    "Name": "quest/meeting/murakami",
    // Status descriptions for each quest stage
    "StatusToDescription": {
      "0": "quest/meeting/murakami/0",
      "1": "quest/meeting/murakami/1",
      "2": "quest/meeting/murakami/2"
    }
  }]
}
```

## Two kinds of status — don't conflate them

A quest carries **two independent state values** (`Core/Quests/`):

| Where | Type | What it means | How it changes |
|---|---|---|---|
| `QuestBase.Status` | `QuestStatus` enum (`byte`) | Quest **lifecycle** — not started / active / finished | `quest.set_status` → `QuestService.SetStatus` |
| `QuestData.Status` (i.e. `quest.Data.Status`) | raw `int` | Numeric **stage counter** used for journal descriptions and fine-grained progress | `quest.set_data_status level=N` → `QuestService.SetDataStatus` |

They are stored and mutated separately. Setting one does not touch the other.

### QuestStatus enum

`QuestStatus` (`Core/Quests/QuestStatus.cs`) is a byte-sized enum with exactly
three values:

| Name | Value | Meaning |
|---|---|---|
| `NotStarted` | 0 | Never accepted or activated; hidden from the journal by default |
| `Progress` | 1 | Accepted, possibly with sub-objectives tracked via tags |
| `End` | 2 | Terminal completed state |

`action quest.set_status` expects the **status names** above as the `status`
parameter; parsing via `Enums<QuestStatus>.Parse` is case-insensitive
(`notstarted`, `Progress`, `END` all work). Invalid names are logged and the
call is ignored.

## Quest tags

Quests use a tag system to track progress. Tags are set via scenario `action` commands:

```scenario
action battle.init scene=town_entry enemies=wolf_2,werewolf_girl_1 quest_id=MeetingMurakamiQuest new_tag=boss_battle
```

This sets the tag `boss_battle` on `MeetingMurakamiQuest` when the battle is initiated.

On the C# side, tags live in a `HashSet<string>` on `QuestBase.Data`
(`Core/Quests/QuestBase.cs`):

| Method | Description |
|---|---|
| `AddTag(tag)` | Adds a milestone tag |
| `RemoveTag(tag)` | Revokes a tag (no-op if absent) |
| `ContainsTag(tag)` | Membership check |
| `NotContainsTag(tag)` | Negated membership check |

Scenario `condition:` expressions map onto these directly: `hasTag(...)` calls
`IQuest.ContainsTag`, `notHasTag(...)` calls `IQuest.NotContainsTag` (resolved
by `QuestMethodCondition`, `Core/Scenes/QuestMethodCondition.cs`).

## Conditional scenarios

Scenarios use quest tags in their `condition:` metadata to decide whether they should run:

```scenario
scene: town_entry
priority: 20
condition: MeetingMurakamiQuest.hasTag("wolf_battle") && MeetingMurakamiQuest.notHasTag("boss_battle")
---
background "town/meetingmurakami/00002-1920617752"
text "[Wolf tracks lead to the door]" key=meeting/after_wolf/tracks
```

This scenario only executes if:
1. The player killed the wolf (`hasTag("wolf_battle")`)
2. The player has NOT yet fought the boss (`notHasTag("boss_battle")`)

## Status progression in practice

The journal reads the current numeric stage (`quest.Data.Status`) and displays
the matching locale key from `StatusToDescription`. Stage advancement happens
via C# or scenario actions:

```scenario
action quest.set_data_status quest_id=AwakeningQuest level=3
```

Lifecycle changes use the named statuses instead:

```scenario
action quest.set_status quest_id=AwakeningQuest status=End
```

To surface a "new journal entry" banner after changing quest state, call:

```scenario
action quest.send_notification quest_id=OldHouseQuest
```

Under the hood this routes to `IQuestExtensions.SendNotification(this IQuest)`
(`Core/Quests/IQuestExtensions.cs`), which posts a localized
`ui/journal/new - <quest name>` message to the notification banner queue. It
does not modify quest state.

## Events emitted

`quest_status_changed` is published from `QuestServiceImpl.SetStatus`
(`Core/Actions/QuestServiceImpl.cs`); `quest_tag_changed` is published from
`QuestBase.AddTag` / `QuestBase.RemoveTag` (`Core/Quests/QuestBase.cs`) — and
only when the tag set actually changed. Both reach GDScript mods through the
ModEventBus string layer (payload keys are snake_case versions of the C#
properties):

| Event key | Payload | Fired when |
|---|---|---|
| `quest_status_changed` | `quest_id`, `old_status`, `new_status` | `QuestService.SetStatus` succeeds |
| `quest_tag_changed` | `quest_id`, `tag`, `added` (bool) | `QuestService.AddTag` (`added=true`) or `RemoveTag` (`added=false`) |

Note: a quest action with an unknown quest ID does nothing — the safe `TryGet`
inside `QuestServiceImpl` logs the miss and skips the mutation, so no event is
published for a quest that doesn't exist.

## Persistence pipeline

`QuestFactory` (`Core/Quests/QuestFactory.cs`) owns quest instances end-to-end:

- `QuestFactory.Instance.Get(id)` — returns the shared runtime `QuestBase`.
  **Throws `KeyNotFoundException`** when the id was never loaded from mod data
  (typically a typo in a scenario reference).
- `QuestFactory.Instance.GetAll()` — snapshot list of every known quest; used
  by the save system to serialize full quest state.
- `QuestFactory.Instance.SetAll(List<QuestBase>)` — save/restore: looks up each
  entry from the save file by ID and overwrites its `Data` (tags + stage) and
  lifecycle `Status`. Empty/null lists are ignored; entries whose quest no
  longer exists (removed mod, renamed quest) are skipped with a warning so an
  ageing save cannot block loading.
- `QuestFactory.Instance.ResetAll()` — returns every quest to `NotStarted` with
  an empty tag set. Called by `GameData.StartNewGame` because the factory
  singleton survives returning to the main menu and would otherwise leak the
  previous playthrough's progress into the new one.

Scenario-facing `QuestService` methods wrap the factory with an internal safe-get,
so a missing quest in an action command logs an error instead of crashing the
scenario.

## Locale keys

Quest names and descriptions are localizable:

```properties
quest/awakening = Awakening
quest/awakening/0 = Woke up naked in an alley. My head is splitting. Look around — find something to wear.
quest/awakening/1 = Covered myself with a rag and grabbed the book. Then the police showed up. No point arguing — run.
quest/awakening/2 = The whistles died down. I'm at the cemetery. Catch my breath and figure out what to do with the book.
quest/awakening/3 = Read it aloud. Pretty sure I did something. Just what exactly?
quest/awakening/4 = The book started talking. Calls itself Al Azif. Time to get out of here.
quest/awakening/5 = Walking along the river to the villa. I'll wait it out somewhere there.
quest/old_house = The Old House

quest/meeting/murakami = The Girl at the Edge of Town
quest/meeting/murakami/0 = A wolf attacked a house on the outskirts. Investigate.
quest/meeting/murakami/1 = The girl inside needs help. Something's in the back room.
quest/meeting/murakami/2 = She's safe. Murakami joined the party.
```

### Tag locale keys

Tags displayed as journal stamps are resolved through a composite key built
from the quest's `Name` prefix and the tag itself: `{quest.Name}/tag/{tag}`.
For `MeetingMurakamiQuest` (`Name` is `quest/meeting/murakami`) the tag
`go_home` maps to `quest/meeting/murakami/tag/go_home`, so same-named tags in
different quests can carry different translations:

```properties
quest/meeting/murakami/tag/wolf_battle = Wolves defeated
quest/meeting/murakami/tag/boss_battle = Werewolf defeated
quest/meeting/murakami/tag/go_home = Escort home
```

When the composite key is missing, the journal falls back to the raw tag id —
a tag without locale entries still appears as a stamp.


## Adding a new quest

1. Create a JSON file in your mod with the `{ "Kind": "quest", "Items": [ ... ] }` envelope (e.g. `quests/quests.json`)
2. Define `Id`, `Name`, and `StatusToDescription`
3. Add locale keys for all status descriptions
4. Set tags via `action battle.init new_tag=X` or dedicated quest actions
5. Write scenarios with `condition:` that check quest state
6. Scenarios with higher `priority` take precedence when conditions match

## Quest conditions — full expression reference

See Conditions for the complete condition expression language.

===== DOC docs/mods/recruiting/index.md: Recruiting =====
The recruiting system manages the post-battle recruit roster. It is a **standalone mod** (`mods/recruiting/src/`): it owns its data, rules, save state and event subscription, and no other mod depends on it. Removing the mod removes recruiting entirely — battles, cooking, story content and the core keep working.

## How it is triggered

The recruiting mod never knows about battles. Whatever system defeats characters publishes a core-neutral event on the global bus:

```csharp
public class CharactersDefeatedEvent : ModEvent
{
    public string   Source       { get; init; }  // "battle", "trap", ...
    public string[] CharacterIds { get; init; }
}
```

- `turn_battle_base` publishes it from the victory screen (`Source = "battle"`) after EXP and loot are processed;
- `RecruitingInitializer` subscribes to it and routes it into `DefeatedCharactersRecruiter`, which resolves the templates through `CharacterFactory`, runs the condition gate, rolls `RecruitmentChance` and adds the winner to the roster.

Because the event type lives in the core and carries ids only, neither side depends on the other, and any mod (or GDScript subscriber via the `characters_defeated` key) can publish or observe group defeats.

## Removing the mod

With `recruiting` disabled:

- `turn_battle_base` still publishes `CharactersDefeatedEvent` — into an empty subscription list;
- `cthangover_game` and `cooking` load normally (they no longer depend on recruiting);
- the save file simply has no `recruiting` slot;
- a core save from an older version opens with an empty roster (no backward-compatible `Recruits` blob is read).

## Data structures

### `Recruit`

| Property | Type | Description |
|---|---|---|
| `ID` | `string` | Unique instance ID (auto-generated Guid) |
| `CharacterID` | `string` | Template character ID (references a character definition) |
| `Properties` | `PropertyData` | Mutable state bag - stores per-recruit custom data |

`Properties` is a free-form key-value bag. The conventional health key is
formalized as the constant `Recruit.PROP_HEALTH` (`"Health"`): `RecruitingData.Add`
seeds it to 10, and behaviours read/write it via
`recruit.Properties.GetInt(Recruit.PROP_HEALTH)` / `SetInt(...)` — e.g. the
built-in `NutritionBehaviour` drains it tick by tick until the recruit is fed.

### `RecruitingData`

The roster is a session singleton — there is exactly one per playthrough:

| Method/Property | Description |
|---|---|
| `Instance` | Static roster owned by the mod (replaces the old `RuntimeData.RecruitingData`) |
| `Data` | `List<Recruit>` - current roster |
| `HasID(id)` | Check if a recruit exists |
| `GetByID(id)` | Look up a recruit by instance ID |
| `HasCharacterID(characterID)` | Check if an enemy template is already captured |
| `GetByCharacterID(characterID)` | Look up the recruit spawned from an enemy template |
| `Add(id, characterID)` | Create a new recruit (uses the given `id`, falling back to a fresh Guid if taken; initialises Health=10 via `Recruit.PROP_HEALTH`; fires `OnConfigure`). **Only one recruit per enemy template is allowed** - a duplicate request returns the existing entry and creates nothing. Returns the `Recruit` instance |
| `Remove(id)` | Remove a recruit from the roster; also detaches it from the party and deletes its placeholder card |

A new game clears `Data` through the `IResettable` the `RecruitingInitializer`
registers in `AppServices`; loading a save replaces it via `RecruitingSaveable`.

## Party integration

The roster itself lives entirely in the mod, but the player can put a captured
recruit into the active party from the mod's own **Recruits** panel (a
`kind:panel` toolbar entry). The panel is a pure view over `RecruitingData`; the
crossing into core data happens in `RecruitPartyBridge`:

| Member | Description |
|---|---|
| `IsInSquad(recruit)` | True when the template ID is in `CharacterData.BattleSet` |
| `AddToSquad(recruit)` | Creates the placeholder `CharacterInfoData` (keyed by `CharacterID`, so the one-per-enemy rule keeps the key unique) via `CharacterData.AddCharacterToParty`, adds it to `BattleSet` and syncs health. Core panels, battle, item targeting and the core save pick the recruit up as a normal party member |
| `RemoveFromSquad(recruit)` | Leaves `BattleSet` but keeps the card, so action assignments and experience survive a temporary leave |
| `Forget(recruit)` | Drops the party entry and deletes the card - used by `RecruitingData.Remove` |
| `SyncToCard(recruit)` | Pushes `Recruit.Properties["Health"]` onto the card, mutating `Attributes.Health` **in place** (battle copies share the object) |
| `SyncFromCard(recruit)` | Pulls the final battle health from the card back into the recruit's property bag |

Synchronization is push-based and owned by the mod: `SyncToCard` runs when the
mod changes its own data (starvation tick, joining the squad) and on save load;
`SyncFromCard` runs on the core-neutral `characters_defeated` event, which marks
the post-battle moment. Outside battle the mod never reads the placeholder card,
so the roster stays the single source of truth.

### `RecruitBehaviourRegistry`

Registry and dispatch hub for recruit lifecycle hooks **and** recruitment
conditions. Implementations are auto-discovered from mod assemblies by
`RecruitingInitializer` — the initializer scans each mod assembly as it is
announced and does a full AppDomain pass at resources-ready. Registration is
id-keyed: a rescan replaces the implementation with the same `Id`, so mod
reloads do not double-dispatch. Dispatch is cross-recruit: every registered
`IRecruitBehaviour` instance receives every active recruit on tick, configure,
and removal events — not just recruits from its own mod.

| Member | Description |
|---|---|
| `Instance` | Static eager singleton (`public static readonly`) - no lazy wrapper, deliberately, to tolerate partial-initialisation during reflection discovery |
| `RegisterAssembly(Assembly)` | Scans one assembly for `IRecruitBehaviour` and `IRecruitCondition` implementations (parameterless constructor required) and registers them by `Id` |
| `RegisterAll()` | Rescans every loaded assembly; used at resources-ready |
| `CanRecruit(Character enemy, RuntimeData runtime)` | Conjunctive gate over all registered conditions - returns true only if **every** `IRecruitCondition` passes |
| `Get(string id)` | Behaviour lookup by its `Id`; linear scan, `null` if absent |
| `All()` | All registered behaviours |
| `OnTick()` | Dispatches `OnTick(recruit, runtime, currentTick)` to every behaviour for every active recruit; lazily ensures the scene timer registration on first call |
| `OnConfigure(Recruit)` | Dispatches `ConfigureRecruit` to every behaviour for a newly added recruit |
| `OnRemove(Recruit)` | Dispatches `OnRemove` to every behaviour for a departing recruit |

### Built-in rules

The mod ships two rules that used to live in content mods:

- `EnslaveCondition` (`IRecruitCondition`, `Id = "enslave"`) — the recruitment gate requires the player to own the `slave_master` skill (checked through `RuntimeData.SkillData`).
- `NutritionBehaviour` (`IRecruitBehaviour`, `Id = "nutrition"`) — seeds `FullnessTime` when a recruit joins and drains `PROP_HEALTH` by 1 per tick once the fullness window has elapsed. The window is a hard-coded `24 * 4 * 60` ticks; at one tick per in-game minute that is 96 in-game hours (4 in-game days), although the source comment calls it "24 hours" — treat the tick constant as authoritative.

Because they live inside `recruiting`, disabling the mod disables the gate and the hunger loop together, without touching the content mods.

## Custom recruit behaviour

Implement `IRecruitBehaviour` to add custom logic on recruit lifecycle events. External mods that implement the interfaces declare `"depends": ["recruiting>=1"]` so their assembly compiles after the mod. Discovery is automatic. Because dispatch is cross-recruit — each behaviour receives every active recruit on each event — a behaviour can also introspect other recruits:

```csharp
public class MyRecruitBehaviour : IRecruitBehaviour
{
    // Unique identifier for RecruitBehaviourRegistry.Get lookup
    public string Id => "my_mod/loyalty";

    public void ConfigureRecruit(Recruit recruit, RuntimeData runtime)
    {
        // Called when a recruit is added - set initial properties;
        // RecruitingData.Add returns the instance, but this hook fires too
        recruit.Properties.SetInt("loyalty", 50);
    }

    public void OnTick(Recruit recruit, RuntimeData runtime, long currentTick)
    {
        // Called every game tick for each active recruit;
        // currentTick is RuntimeData.Time.Tick - use it for periodic logic
    }

    public void OnRemove(Recruit recruit, RuntimeData runtime)
    {
        // Called when a recruit is removed - clean up state
    }
}
```

## `IRecruitCondition`

A gating predicate consulted before any recruitment roll happens
(`mods/recruiting/src/IRecruitCondition.cs`). Conditions receive the defeated
`Character` template and the full `RuntimeData`, so they can inspect stats,
quest state, inventory, or relationship flags:

```csharp
public class MyRecruitCondition : IRecruitCondition
{
    // Unique identifier for debugging and override lookups
    public string Id => "my_mod/level_cap";

    public bool CanRecruit(Character enemy, RuntimeData runtime)
    {
        return enemy.Level <= 5;
    }
}
```

Rules:

- Conditions are auto-discovered by `RecruitingInitializer` - just ship a public class.
- The gate is **conjunctive**: all discovered conditions must return `true`.
  A single `false` blocks recruitment for that enemy - mods compose gates
  without coordinating with each other.
- If no conditions are registered at all, the gate passes vacuously.

## `RecruitTickController`

Periodic tick handler that processes recruit-related updates (e.g. hunger depletion, status regeneration). It registers itself with the scene's `SceneEventController` via `AddTimerTickEventListener` on the first lifecycle call, so no timer cost is paid when no behaviours are loaded.

## Recruitment flow

1. Characters are defeated and a system publishes `CharactersDefeatedEvent` (e.g. `turn_battle_base`'s victory screen); the event also pulls the final battle health of every squad recruit back through `RecruitPartyBridge.SyncFromCard`;
2. `DefeatedCharactersRecruiter` resolves each id through `CharacterFactory` and skips templates with `RecruitmentChance <= 0` and enemies that are already captured (one recruit per enemy type);
3. The condition gate must pass: `RecruitBehaviourRegistry.CanRecruit` (all `IRecruitCondition`s, including the built-in `EnslaveCondition`);
4. The engine rolls 1-100 against the enemy's `RecruitmentChance`;
5. On success the enemy is registered via `RecruitingData.Instance.Add`;
6. In the Recruits panel the player presses "Add to party": `RecruitPartyBridge.AddToSquad` creates the placeholder `CharacterInfoData` entry and adds the template ID to `BattleSet`;
7. Character joins the battle set and becomes controllable; the recruit's health is pushed onto the card, and the character actions panel exposes the standard three action slots.

## Save state

`RecruitingSaveable` implements `IModSaveable` and stores the roster in the mod's own slot (mod id `recruiting`) as a `Recruits` `DataBlob`. `ModSaveableRegistry` discovers it through `ModAssemblyLoader` automatically. The core save (`__core__` slot) no longer contains a `Recruits` blob, and backward compatibility with old saves is not provided — old saves start with an empty roster.

On load the roster is replaced wholesale without re-firing `ConfigureRecruit`: persisted per-recruit properties (for example the nutrition timer) are restored as-is, so behaviours continue their tick logic from the saved state.

## Scenario integration

Use `character.add_to_party` to recruit directly from a scenario:

```scenario
action character.add_to_party type=Murakami
```

Show a notification without roster changes:

```scenario
action character.send_notification type=Murakami
```

## Relationship to skills

Recruited characters can acquire skills. `SkillData` is not a singleton - it is a plain instance owned by `RuntimeData`, accessible as `GameData.Instance.Runtime.SkillData`. It holds a single global set of player-owned skill IDs (lowercase strings such as `"slave_master"`) and is persisted with the save. Skills are not tied to individual recruits - they are a global unlock pool.

===== DOC docs/mods/save/api.md: Mod Save API =====
## Implementing IModSaveable in a mod

Your mod must provide a class implementing `IModSaveable`. It is auto-discovered via reflection when your mod assembly loads.

### API

```csharp
public interface IModSaveable
{
    // Called on save. Slot metadata (ModId, ModName, ModVersion) is
    // already filled by the core. Set MinCoreVersion, IsRequired,
    // CustomData, and write Data[...].
    void OnSave(ModSlot slot);

    // Preferred: explicit contract. Return false + error message on
    // failure. Default impl calls OnLoadVoid for backward compat.
    bool OnLoad(ModSlot slot, out string errorMessage);

    // Legacy: throw-based error handling. Override instead of OnLoad
    // if you prefer exceptions for error reporting.
    void OnLoadVoid(ModSlot slot);
}
```

### Minimal example

```csharp
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;

public class MyModSaveHandler : IModSaveable
{
    public void OnSave(ModSlot slot)
    {
        // ModId, ModName, ModVersion already set by core.
        // Set only what the core needs for validation:
        slot.Metadata.IsRequired = true;

        var blob = new DataBlob();
        blob.SetInt("Health", player.Health);
        blob.SetInt("Mana",   player.Mana);
        blob.SetString("Name", player.Name);

        slot.Data["Stats"] = blob;
    }

    public bool OnLoad(ModSlot slot, out string errorMessage)
    {
        if (slot.Data.TryGetValue("Stats", out var blob))
        {
            player.Health = blob.GetInt("Health", 100);
            player.Mana   = blob.GetInt("Mana", 50);
            player.Name   = blob.GetString("Name", "Hero");
            errorMessage = null;
            return true;
        }

        errorMessage = null;
        return true; // No data → nothing to restore, not an error
    }
}
```

### Versioned migration

When your data schema changes, use `DataBlob.Version` to handle migration:

```csharp
public void OnSave(ModSlot slot)
{
    var data = new PlayerStateV2 { Health = 80, Mana = 40, Stamina = 100 };
    slot.Data["FullState"] = new DataBlob(version: 2, data);
}

public bool OnLoad(ModSlot slot, out string errorMessage)
{
    if (!slot.Data.TryGetValue("FullState", out var blob))
    {
        errorMessage = null;
        return true; // no data — nothing to restore
    }

    if (blob.Version == 2)
    {
        var state = blob.ReadJson<PlayerStateV2>();
        Apply(state);
        errorMessage = null;
        return true;
    }

    if (blob.Version == 1)
    {
        var old = blob.ReadJson<PlayerStateV1>();
        Apply(new PlayerStateV2
        {
            Health  = old.Health,
            Mana    = old.Mana,
            Stamina = 100, // default for new field
        });
        errorMessage = null;
        return true;
    }

    errorMessage = $"Unsupported data version: {blob.Version}";
    return false;
}
```

### Binary data

`DataBlob` can hold raw bytes: construct it from a `byte[]`, or use the binary API (`GetBytes`/`SetBytes`) to read and write raw data.

```csharp
public void OnSave(ModSlot slot)
{
    slot.Data["Skin"] = new DataBlob(textureBytes);
}

public bool OnLoad(ModSlot slot, out string errorMessage)
{
    if (slot.Data.TryGetValue("Skin", out var blob))
    {
        byte[] bytes = blob.GetBytes();
        // Decode the bytes yourself, e.g. via Godot's Image + ImageTexture.
        ApplySkin(bytes);
    }

    errorMessage = null;
    return true;
}
```

### Marking a mod as optional

If a save can be loaded without your mod, set `IsRequired = false`:

```csharp
slot.Metadata.IsRequired = false;
```

The core will log a warning and skip the mod's data if the mod is not installed.

### Compatibility guard (MinCoreVersion)

If your save data depends on a specific core API, set the minimum version:

```csharp
slot.Metadata.MinCoreVersion = "0.2.0";
```

On load, if the current core version is below `0.2.0`, the load is aborted with an error message.

## Registration & dispatch (ModSaveableRegistry)

You never invoke the registry yourself — implementations are discovered **automatically via reflection** when your mod assembly is loaded (`ModAssemblyLoader.RegisterAssembly` scans it with `Reflections.FindAndCreate<IModSaveable>` and registers every implementation under your mod's ID).

| Method | When | Behaviour |
|---|---|---|
| `ModSaveableRegistry.RegisterAssembly(assembly, modId)` | Mod assembly load | Scans the assembly and creates one instance per `IModSaveable` implementation |
| `CollectSaveData() : Dictionary<string, ModSlot>` | Save time | Per registered mod: builds a `ModSlot` with `ModId`, `ModName`, `ModVersion` pre-filled from `IModInfo`, then calls `OnSave(slot)` |
| `DispatchLoad(Dictionary<string, ModSlot>) : List<string>` | Load time (phase 3) | Per registered mod whose ID is present in the loaded dictionary, calls `OnLoad(slot, out error)`; collects error strings (empty list = success) |

Error handling rules:

- An exception thrown from `OnSave` is logged and **that mod's slot is skipped**; other mods keep saving.
- If you override neither load method, the defaults chain into `void OnLoadVoid(ModSlot)`, which throws `NotSupportedException` — override at least one of `bool OnLoad(...)` or `OnLoadVoid`.
- A `false` return from `OnLoad` (or an exception from either overload) is converted to `Mod '<id>' (<name>): <message>`; any such error aborts the whole load after phase 3.

## Save slot API (SaveService)

| Member | Description |
|---|---|
| `Path : string` (static property) | Saves directory — globalised `user://saves` at runtime, `"saves"` otherwise |
| `Save(string fileName)` | Build `"__core__"` slot, collect mod slots, write JSON |
| `Load(string fileName) : bool` | Deserialize, validate + restore + dispatch |
| `GetSaveSlots(int slotCount) : List<SaveSlotInfo>` | Enumerate slots with preview metadata |
| `DeleteSave(string fileName)` | Delete JSON + PNG screenshot |

## Slot preview API (RuntimeSerializer)

| Member | Description |
|---|---|
| `RuntimeSerializer.ReadSlotPreview(ModMetadata meta) : CoreSlotPreview` | Deserialises the preview JSON from `ModMetadata.CustomData`; returns a default (empty) preview when the field is absent or malformed |
| `RuntimeSerializer.CoreSlotPreview` | Nested public DTO: `CurrentSceneName` (`string`), `GameTime` (`long`), `CharacterCount` (`int`) |

Lets a custom save-slot UI read slot info without deserialising any game-state blobs.

## Save screenshots (SaveScreenshotService)

| Method | Description |
|---|---|
| `CaptureAndSave(string fileName)` | Grabs the current background texture from the `ViewBox` node, downscales it to 320×180 with Lanczos interpolation, writes `user://saves/{fileName}.png` (same name sanitisation as the JSON save) |
| `LoadScreenshot(string fileName) : Texture2D` | Reads the thumbnail PNG back as a `Texture2D`; returns `null` if the file is missing or fails to decode |

Notes:

- If no valid background texture exists at capture time, a solid dark-grey **64×64 fallback** image is saved instead.
- The thumbnail is a **separate PNG file** next to the save JSON — it is not part of `SavePackage`. Only the textual preview (`CurrentSceneName`, `GameTime`, `CharacterCount`) is embedded in the save, inside `ModMetadata.CustomData` of the `"__core__"` slot (see Data Model).
- `DeleteSave(fileName)` removes both the JSON and the PNG.

===== DOC docs/mods/save/data-model.md: Save Data Model =====
## Save file structure (SavePackage)

| Field | Type | Description |
|---|---|---|
| `SaveId` | `string` | User-facing save name (e.g. "slot_1") |
| `Timestamp` | `DateTime` | UTC timestamp when the save was created |
| `CoreVersion` | `string` | Version of the game core that created this save |
| `ModData` | `Dict<string, ModSlot>` | Data slots from core + all mods |

## `"__core__"` slot structure

The core slot uses these blob keys:

| Blob key | Content type | Description |
|---|---|---|
| `Time` | `long` | In-game clock tick (minutes elapsed) |
| `Lamp` | key-value | `Radius` (float), `Influence` (float) |
| `Characters` | `List<CharacterInfoData>` | Recruited character snapshots |
| `Quests` | `List<QuestBase>` | Quest instances with progress |
| `BattleSet` | `List<string>` | Active battle party character IDs |
| `Inventory` | `List<CItem>` | Inventory item stacks |
| `Recipes` | `List<string>` | Unlocked recipe IDs |
| `ActionPool` | `List<string>` | Persistent action card pool IDs |
| `Skills` | `List<string>` | Owned skill IDs |
| `CompletedScenarios` | `List<string>` | One-shot scenario IDs already run |
| `MapZones` | `MapZoneStateData` | World-map zone open/visible overrides (delta against the mod-authored defaults) |

Slot preview metadata (`CurrentSceneName`, `GameTime`, `CharacterCount`) is stored in `ModMetadata.CustomData` as a JSON object, so the slot list UI can read it without deserialising game-state blobs.

Subsystems **not persisted** by `BuildCoreSlot` today: there are no blob keys for the active battle encounter (held by the turn-battle mod in `BattleRequest.Current`), so an encounter in progress resets between sessions. The recruit roster is not a core blob either: it is persisted by the `recruiting` mod in its own `ModSlot` through `IModSaveable`.

The saved `CurrentSceneName` is used for the slot-list UI and scene navigation after loading; `RuntimeData.CurrentScene` itself is not stored — it is a read-only property resolved live from the `SceneManager`/current scene at runtime.

## ModSlot

| Field | Type | Description |
|---|---|---|
| `Metadata` | `ModMetadata` | Structured metadata for core validation |
| `Data` | `Dict<string, DataBlob>` | Named data blocks defined by the mod |

## ModMetadata

The core pre-fills `ModId`, `ModName`, and `ModVersion` from `IModInfo` before calling `OnSave`. The mod only needs to set `MinCoreVersion`, `IsRequired`, `CustomData`, and the data blobs.

| Field | Type | Set by | Description |
|---|---|---|---|
| `ModId` | `string` | core | Mod ID (from `IModInfo.Id`) |
| `ModName` | `string` | core | Human-readable name (from `IModInfo.DisplayTitle`) |
| `ModVersion` | `string` | core | Version of the installed mod (from `IModInfo.Version`, read from `manifest.json`) |
| `MinCoreVersion` | `string` | mod | Minimum core version required |
| `IsRequired` | `bool` | mod | If true, save cannot load without this mod (default: true) |
| `CustomData` | `string` | mod | Free-form JSON for extra metadata |

## DataBlob

A versioned byte container with three access modes:

| Mode | Methods | Use case |
|---|---|---|
| **Key-value** | `GetInt/SetInt`, `GetFloat/SetFloat`, `GetString/SetString`, `GetBool/SetBool` | Simple fields like HP, mana, lamp radius |
| **JSON** | `ReadJson<T>()`, `WriteJson<T>(data)` | Complex objects with versioned schemas |
| **Binary** | `GetBytes()`, `SetBytes(byte[])` | Raw data (textures, compressed payloads) |

Key-value and JSON modes conflict — calling `WriteJson` overwrites the internal dictionary, and calling `SetInt` after `WriteJson` replaces the JSON object with a dictionary. Choose one mode per blob.

### DataBlob constructors

```csharp
new DataBlob();                          // version=1, empty
new DataBlob(version: 2);               // version=2, empty
new DataBlob(version: 2, myObject);      // version=2, serialises myObject as JSON byte payload
new DataBlob(rawBytes);                  // version=1, stores raw bytes
```

The raw payload is also exposed directly via the public `Payload` property (`byte[]`; assigning `null` is coerced to an empty array). Unlike `GetBytes()`, reading `Payload` does **not** flush pending key-value entries into the byte array first.

## SaveSlotInfo

Returned by `SaveService.GetSaveSlots(slotCount)` for the load-game UI. There is no numeric slot ID/index field — the logical slot identity is `FileName` (e.g. `slot_1`).

| Field | Type | Description |
|---|---|---|
| `FileName` | `string` | Logical slot name (e.g. `slot_1`), used as the save-file identifier |
| `SaveTime` | `DateTime` | UTC timestamp from `SavePackage.Timestamp` (`DateTime.MinValue` for empty slots) |
| `SceneName` | `string` | Scene at save time (from the core slot preview; `"unknown"` fallback) |
| `ScreenshotPath` | `string` | Godot path of the thumbnail PNG (e.g. `user://saves/slot_1.png`) |
| `HasScreenshot` | `bool` | `true` if a thumbnail PNG exists on disk |
| `IsEmpty` | `bool` | `true` when no JSON save file exists for this slot |
| `GameTime` | `long` | In-game time tick at save moment |
| `CharacterCount` | `int` | Number of characters at save moment |
| `LampPercent` | `float` | **Known placeholder**: currently always written as `0f` by `GetSaveSlots` — no real lamp data is computed yet |

===== DOC docs/mods/save/index.md: Save System =====
The save system uses a mod-aware dispatch architecture. All game state — core and mod alike — lives in named slots inside `SavePackage.ModData`. The core writes its state into the reserved `"__core__"` slot; mods write into their own named slots via `IModSaveable`.

## Architecture

```mermaid
flowchart TD
    subgraph SaveFlow["Save Flow"]
        direction TB
        S1["Save()"] --> S2["Build '__core__' slot"]
        S1 --> S3["CollectSaveData()"]
        S2 --> S3b["Metadata, Time, Characters, ..."]
        S3 --> S4["For each mod:\npre-fill slot.Metadata\n&#40;ModId, ModName, ModVersion&#41;"]
        S4 --> S5["mod.OnSave&#40;slot&#41;"]
        S5 --> S5a["Mod writes:\nMinCoreVersion, IsRequired, Data[...]"]
        S3b --> S6["Merge + Serialise"]
        S5a --> S6
    end

    subgraph LoadFlow["Load Flow"]
        direction TB
        L1["Load()"] --> L2["Validate all ModData slots"]
        L2 --> L2a["IsRequired mod installed?"]
        L2 --> L2b["CoreVersion >= MinCoreVersion?"]
        L2 --> L2c["ModVersion comparison\n&#40;downgrade/migration&#41;"]
        L2a --> L3{"Pass?"}
        L2b --> L3
        L2c --> L3
        L3 -->|yes| L4["Restore core state\nfrom '__core__'"]
        L3 -->|no| LErr["Abort"]
        L4 --> L5["DispatchLoad() → mods"]
        L5 --> L6["mod.OnLoad&#40;slot, out error&#41;"]
        L6 --> L6a["Read slot.Data[...]"]
        L6 --> L6b["Check blob.Version"]
        L6 --> L6c["Migrate if needed"]
    end

    S6 -->|writes| SavePkg["SavePackage\nModData dict"]
    SavePkg -->|reads| L1
```

## Core concepts

### Uniform slots

Every piece of persisted state lives in a `ModSlot` under `SavePackage.ModData`. There is no distinction between "core" and "mod" data at the storage level — the core just happens to own the reserved key `"__core__"`.

### Dispatcher (core)

The core is a lightweight dispatcher. It:
- Builds its own `"__core__"` slot with named `DataBlob` blocks per subsystem.
- Collects slots from mods and merges them into the save package.
- Validates mod metadata on load (required mods present, version compatibility).
- **Never inspects mod data contents** — it works only with byte arrays and version numbers.

## Load flow (three phases)

1. **Phase 1 — Validation** (via `ModMetadata` of every slot):
   - For each `ModSlot` in `ModData`:
     - If `IsRequired == true` and the mod is not installed → **abort**.
     - If `CoreVersion < MinCoreVersion` → **abort**.
     - **Mod version comparison** (if both save and installed mod have versions):
       - `save.Version > installed.Version` → **abort** (downgrade not supported).
       - `save.Version < installed.Version` → **warning** (mod must handle migration in `OnLoad`).
2. **Phase 2 — Core state restore**: read blobs from `ModData["__core__"]` and push values into `RuntimeData`.
3. **Phase 3 — Mod dispatch** (`ModSaveableRegistry.DispatchLoad`):
   - For each installed mod whose ID is in `ModData` (excluding `"__core__"`), call `OnLoad(slot, out error)`.
   - Two overloads supported: `bool OnLoad(ModSlot, out string)` and legacy `void OnLoadVoid(ModSlot)` (throw on error).

## Reserved slot key

The key `"__core__"` is reserved for the core game state. If a mod attempts to write a slot with this key, the save service logs a warning and skips it.

## Pages

- Data Model — SavePackage, __core__ slot, ModSlot, ModMetadata, DataBlob
- Mod API — IModSaveable interface, examples, SaveService
- Versioning & Compatibility — version comparison, backward compatibility, rules

===== DOC docs/mods/save/versioning.md: Versioning & Compatibility =====
## Backward compatibility

Old saves (without `ModData`, `SaveId`, `Timestamp`, `CoreVersion` fields) will fail to load because the `"__core__"` slot is required. The old `SaveData` format must be migrated manually or via a one-time converter tool.

## Version comparison

During phase-1 validation the core compares `ModMetadata.ModVersion` from the save with `IModInfo.Version` from the installed mod. Both values originate from the `"version"` field in `manifest.json`.

### Comparison strategy

The core uses **per-segment comparison** via `VersionHelper.Compare` (Core/Utils/VersionHelper.cs). There is no `System.Version` parsing anywhere — the algorithm is:

1. Both strings are trimmed and split on `.`. The shorter segment array is padded with `"0"` to equal length (`"1.0"` equals `"1.0.0"`).
2. Segments are compared **pairwise, left to right**. For each position: if *both* segments parse as `int`, they are compared numerically (handles `"9" < "10"`); otherwise that segment pair is compared with an ordinal string comparison (`String.CompareOrdinal`).
3. The first non-zero comparison decides the result.

Consequences: a segment like `"1b"` does **not** parse as int, so it compares **ordinally**, not numerically. Mixed versions are fine — each segment independently picks numeric or ordinal comparison.

### Supported version formats

| Format | Example | Comparison |
|---|---|---|
| Numeric segments | `1.0.0`, `2.5.3.1`, `1.0` | Numeric per segment; shorter string padded with `"0"` |
| Letter in a segment | `1b.2.0` | That segment compares ordinally (`"1b"` vs `"10"` is ordinal, not numeric) |
| Prefixed tags | `v1`, `v2.0` | Ordinal per segment (`"v2" > "v10"` ordinally) |
| Codename-style | `Enigma1.5`, `Enigma2.0` | Ordinal per segment |
| Alpha/beta tags | `alpha-1`, `beta-3` | Ordinal per segment |

### Validation rules

| Condition | Result |
|---|---|
| No version in save or installed mod | Skip check |
| `save.Version > installed.Version` | **Abort** — save was made by a newer mod; downgrade not supported |
| `save.Version < installed.Version` | **Warning** — save is from an older mod version; the mod must handle migration in `OnLoad` |
| `save.Version == installed.Version` | OK |

## Adding version to manifest.json

Every mod's `manifest.json` must include a semver version string:

```jsonc
{
  "name": "My Mod",                                        // Human-readable mod name
  "version": "1.0.0",                                      // Semver version — used for save validation and migration detection
  "author": "...",                                         // Author credit
  "description": "...",                                    // Short description
  "sources": ["src/**/*.cs"]                              // Glob patterns for C# source files
}
```

This value flows through `ModManifest.Version` → `IModInfo.Version` → `ModMetadata.ModVersion` and is used for the save-system version checks above.

## Important rules for mod authors

- **Add `"version"` to `manifest.json`** — the core uses it for save validation and migration detection.
- **One `IModSaveable` per mod** — create a single implementation in your assembly.
- **Never set `ModId`, `ModName`, or `ModVersion`** — the core pre-fills them. Set `MinCoreVersion`, `IsRequired`, `CustomData`, and `Data`.
- **Prefer `bool OnLoad(ModSlot, out string)`** — explicit contract, no exceptions. Override `OnLoadVoid` only for legacy/compatibility.
- **Mods must not depend on each other's save data** — the dispatch order is undefined.
- **Version your `DataBlob` blocks** — use separate blobs per logical entity, version each independently.
- **Handle missing keys** — use `TryGetValue` and provide defaults.

===== DOC docs/mods/scenes/index.md: Scenes =====
Scene definitions map scene IDs to default backgrounds, ambient music, and entry-point scenarios. Scenes are the navigation graph of the game — the player moves between them.

## JSON structure

```jsonc
{
  "name": "scene_id",                                       // Unique scene identifier (referenced by switch_scene)
  "defaultBackground": "path/to/background",                // Path to background image loaded on scene enter (or array of paths)
  "defaultAmbient": "playlist_id",                          // Playlist ID for background music (empty = no music)
  "defaultScenario": "scenarios/path/to/default.scenario"   // Scenario file that runs when the scene loads
}
```

## Field reference

| Field | Description |
|---|---|
| `name` | Unique scene identifier (e.g. `home_kitchen`, `town_entry`). Referenced by `switch_scene` in DSL |
| `defaultBackground` | Path to the background image loaded on scene enter (relative to `backgrounds/`). Can be a single string or an array of strings for multiple backgrounds |
| `defaultAmbient` | Playlist ID for background music (from `music/playlists/`). Empty string = no music |
| `defaultScenario` | Path to the scenario file that runs when the scene loads (relative to mod root) |

## Real examples

From `mods/cthangover_game/scenes/`:

```jsonc
{
  "name": "home_kitchen",                                 // Scene ID — used with switch_scene and ui[] entries
  "defaultBackground": "home/kitchen",                    // Background image path (relative to backgrounds/)
  "defaultAmbient": "",                                   // No ambient music for this scene
  "defaultScenario": "scenarios/home/home_kitchen_default.scenario"  // Entry-point scenario
}
```

```jsonc
{
  "name": "home_vestibule",                                   // Scene ID — vestibule/entrance of the player's home
  "defaultBackground": "home/vestibule",                      // Background image path (relative to backgrounds/)
  "defaultAmbient": "",                                       // No ambient music for this scene
  "defaultScenario": "scenarios/home/home_vestibule_default.scenario"  // Entry-point scenario
}
```

## Scene navigation

Scenes are connected through the `switch_scene` DSL command:

```scenario
switch_scene home_vestibule
end
```

And through `option` jumps that lead to `switch_scene`:

```scenario
select "[Where to?]" key=home_outside/prompt
option "Enter the house" key=home_outside/opt_enter -> :enter
option "To the town" key=home_outside/opt_town -> :town

:enter
switch_scene home_vestibule
end

:town
switch_scene town_entry
end
```

## Scene lifecycle

When the player enters a scene, the engine:

1. Collects the scene's events — `.scenario` files targeting the scene plus `[SceneEvent]` C# classes (see Scene Event Metadata) — and sorts them by `priority` with `after:` dependencies resolved
2. Publishes the `scene_entered` lifecycle event on the Event Bus; engine subscribers apply `defaultBackground` (subscriber priority 0), start `defaultAmbient` (5), and update the music type (10)
3. Evaluates every event's `condition:` and enqueues the passing scenarios into the scene's event chain, where they run one after another in enqueue order
4. If no events are registered at all, falls back to the scene's `defaultScenario`
5. When leaving, publishes `scene_exited`

## Hooking into scenes from mods

There is no manifest subscription list; mods extend a scene in one of three ways:

- **A `.scenario` file targeting the scene** — the most common hook. The Cooking mod adds a "Cook" option to the kitchen with a `home_kitchen` scenario at a lower `priority` than the core's default one.
- **A `[SceneEvent]` C# class** — declarative code hook compiled into the mod assembly; runs in the same event chain as scenarios (see Scene Event Metadata).
- **A manifest `ui` entry bound to the scene** — panels and overlays declared in `manifest.json` accept a `"scene": "home_kitchen"` field; the element is then only available in that scene and is hidden when the player leaves it.

## Scenario priority system

Multiple `.scenario` files can target the same `scene:`. The one with the **lowest** `priority` number runs first. If that scenario's `condition:` is unmet, the next-highest priority scenario is tried.

```mermaid
flowchart LR
    A["home_kitchen_cooking.scenario<br/>priority: 5"] -->|runs first| S{{"Scene:<br/>home_kitchen"}}
    B["home_kitchen_default.scenario<br/>priority: 10"] -->|fallback| S
```

This allows mods to extend scenes non-destructively by writing additional scenario files with lower priority numbers.

## Adding a new scene

1. Create `scenes/{name}.json` with background, playlist, and default scenario
2. Create the background image(s) in `backgrounds/{name}/`
3. Write the default scenario in `scenarios/{path}.scenario`
4. Add `switch_scene {name}` commands to an existing scene to create a navigation path
5. Register a `[SceneEvent]` class, add extra `.scenario` files targeting the scene, or declare a `ui` entry with `"scene": "{name}"` in your manifest if needed

===== DOC docs/mods/scenes/scenarios/index.md: Scenarios — DSL Reference =====
`.scenario` files are the heart of the game's narrative system. Each file contains a metadata header, a `---` separator, and a script body written in a custom domain-specific language (DSL).

## File structure

```scenario
scene: target_scene_name
priority: 10
condition: QuestId.hasTag("tag_name")
save_allowed: true
is_one_run: false
light_use_time: true
---
# Script body starts after ---
background "path/to/bg"
text "- Hello, traveller." key=locale_key first=marao/normal
select "[What do you do?]" key=choice_key
option "Ask about the town" key=opt_1 -> :town
option "Leave" key=opt_2 -> :exit
:exit
switch_scene home_outside
end
```

## Metadata fields (before `---`)

| Field | Type | Default | Description |
|---|---|---|---|
| `scene` | string | *(required)* | Scene this scenario targets. Multiple scenario files can target the same scene |
| `priority` | int | `0` | Execution order — **lower runs earlier**. All matching scenarios are enqueued in this order |
| `after` | string | *(none)* | Scenario ID that must come before this one in the chain (ordering dependency) |
| `condition` | expression | *(none)* | Quest-condition expression. Scenario only runs if true. See `dsl/conditions/` |
| `save_allowed` | bool | `false` | Whether the player can save the game from this scene |
| `is_one_run` | bool | `false` | If true, the scenario runs only once per playthrough. After execution, it's skipped forever |
| `light_use_time` | bool | *(unset)* | If `true`, the lighting system uses the in-game time-of-day for light colors/angles; `false` forces static light colors. When the field is omitted the scene's default behavior is kept (it is parsed as a nullable bool) |

## Real examples

### Default kitchen scenario (core)

```scenario
scene: home_kitchen
priority: 10
---
light_set [{"x":0.43,"y":0.58,"radius":500,"influence":1,"color":"#ffff00"},{"x":0.10,"y":0.27,"radius":500,"influence":1,"color":"#ffff00"}]
select "A room equipped as a kitchen" key=rooms/kitchen/prompt
option "Leave" key=rooms/kitchen/opt_exit -> :exit

:exit
switch_scene home_vestibule
end
```

### Cooking mod extension (lower priority = runs earlier)

```scenario
scene: home_kitchen
priority: 5
---
light_set [{"x":0.43,"y":0.58,"radius":500,"influence":1,"color":"#ffff00"}]
select "A room equipped as a kitchen" key=rooms/kitchen/prompt
option "Leave" key=rooms/kitchen/opt_exit -> :exit
option "Cook" key=rooms/kitchen/opt_cook -> :cook

:exit
switch_scene home_vestibule
end

:cook
action toggle_cooking_workbench
end
```

Because `priority: 5 < 10`, the cooking scenario is enqueued **before** the default one. Note that it does **not** replace the default — both scenarios are enqueued and run in priority order. Use `condition:` (or `is_one_run: true`) to exclude scenarios from execution, not priority.

### Bedroom with save_allowed (core)

```scenario
scene: home_bedroom
save_allowed: true
priority: 10
---
select "Your bedroom. You can rest here." key=rooms/bedroom/prompt
option "Leave" key=rooms/bedroom/opt_exit -> :exit

:exit
switch_scene home_vestibule
end
```

### One-run event (interactives test suite)

```scenario
scene: interactives_test
priority: 10
is_one_run: true
---
interactive_add kitchen_lamp
end
```

The interactive lamp is added once, on first visit. Subsequent visits skip this scenario.

## Adding or replacing a scenario

A scenario is identified by its **file name** (without `.scenario`): that is the
id the completion history, `after:` links and the studio use.

- **Add** a scenario to any scene — even one from another mod — by giving it a
  name of your own (prefix it with your mod or game, e.g. `my_novel_town_shop`).
  It runs next to the scene's other scenarios, ordered by `priority`.
- **Replace** another mod's scenario by shipping a file with the **same name**
  and adding that mod to your `depends`. Mods load dependencies first, so your
  file wins; it may even target a different scene. The original mod is not
  changed.
- Two mods that ship the same name without a dependency between them have no
  defined winner — the resource atlas reports this as an error.

## How the engine selects scenarios

1. Player enters `scene: home_kitchen`
2. Engine collects all `.scenario` files targeting `home_kitchen` across all mods
3. Sorts by `priority` (ascending) and resolves `after:` dependencies (topological order)
4. For each scenario in that order, evaluates `condition:` (if present). A scenario **without** a condition always passes.
5. **Every** scenario whose condition passes is enqueued into the scene's event chain — there is no first-match cut-off. Scenarios then execute one after another in enqueue order.

Priority therefore controls *ordering*, not selection: a lower-priority number means the scenario runs earlier in the chain, alongside all other passing scenarios.

===== DOC docs/mods/scenes/scenarios/actions/index.md: Scenario Actions =====
The `action` command bridges the DSL to game systems. Actions can be implemented in **C#** (via `IScenarioAction` + Roslyn compilation) or in **GDScript** (via `get_name()` + `run(ctx)` auto-detection).

## Syntax

```scenario
action namespace.action_name param1=value1 param2=value2 ...
```

Parameters use `key=value` syntax (no spaces around `=`).

## Real examples

### Character actions

```scenario
action character.add_to_party type=Murakami
```

Adds the character `Murakami` to the player's party. `type=` matches the character's `Id` field in their JSON definition.

```scenario
action character.send_notification type=Murakami
```

Triggers the "new party member" UI notification for character `Murakami`.

```scenario
action character.add_skill skill_id=slave_master
```

Grants the skill `slave_master` to the player. If the skill defines a `GrantsAction`, the action card is placed into the action pool for assignment in the character panel. Already owned skills are ignored; unknown IDs are logged and skipped.

### Battle initiation

```scenario
action battle.set_core core=card_battle
action battle.init scene=town_entry enemies=wolf_2,werewolf_girl_1 quest_id=MeetingMurakamiQuest new_tag=boss_battle
```

`battle.set_core` selects the combat ruleset before `battle.init` starts the encounter. Core IDs are **case-sensitive** and unknown IDs throw; the shipped cores register as `card_battle` and `ff_battle`. Both actions are declared by the `turn_battle_base` mod.

| Parameter | Description |
|---|---|
| `scene` | Scene to return to after battle |
| `enemies` | Comma-separated list of enemy character IDs |
| `quest_id` | Quest to update on battle outcome |
| `new_tag` | Tag to set on the quest when the battle starts |

### UI panels

```scenario
action ui.toggle_panel name=cooking_workbench
```

`name=` is required. The panel is first looked up as a `Control` node in the scene tree by node name; if nothing matches, the name is treated as a mod UI entry id from the manifest `ui` list and toggled through the UI registry — this is how the cooking mod's workbench overlay opens. A mod can also wrap the call into its own action: the cooking mod registers `toggle_cooking_workbench` for exactly that.

## Available action namespaces

### `character.*`

| Action | Parameters | Description |
|---|---|---|
| `character.add_to_party` | `type=CharId` | Add character to player's party |
| `character.send_notification` | `type=CharId` | Show "new member" notification |
| `character.add_skill` | `skill_id=SkillId` | Grant a skill and its action card |

### `battle.*`

| Action | Parameters | Description |
|---|---|---|
| `battle.init` | `scene`, `enemies`, `quest_id`, `new_tag` | Start a battle |
| `battle.set_core` | `core=card_battle` | Select combat ruleset by its case-sensitive core ID |

### `quest.*`

| Action | Parameters | Description |
|---|---|---|
| `quest.set_status` | `quest_id=Q status=Progress` | Set the quest lifecycle state by `QuestStatus` enum name: `NotStarted`, `Progress`, or `End` |
| `quest.set_data_status` | `quest_id=Q level=3` | Set the quest numeric progress counter |
| `quest.add_tag` | `quest_id=Q tag=T` | Add a tag to a quest |
| `quest.remove_tag` | `quest_id=Q tag=T` | Remove a tag |
| `quest.send_notification` | `quest_id=Q` | Trigger "Quest Updated" UI popup |

> **Note:** The `status` parameter is parsed as a case-insensitive `QuestStatus` **enum name** (`NotStarted` / `Progress` / `End`) via `Enums<QuestStatus>.Parse`. Use enum names in scenarios. Numeric strings such as `status=2` only happen to work because `Enum.Parse` aliases numbers to enum values — do not rely on this. For numeric progress use `quest.set_data_status` with `level=N` instead.

### `scene.*`

| Action | Parameters | Description |
|---|---|---|
| `scene.instantiate` | `path=res://scenes/ui/CookingPanel.tscn name=CookingNode` | Load and add a PackedScene to the scene tree |
| `scene.remove_object` | `name=CookingNode` | Remove a named node from the scene tree |

### `ui.*`

| Action | Parameters | Description |
|---|---|---|
| `ui.toggle_panel` | `name=PanelName` | Toggle a `Control` node by scene name, or a manifest `ui` entry by id |

### `lighting.*`

| Action | Parameters | Description |
|---|---|---|
| `lighting.clear_map` | *(none)* | Reset lighting depth/albedo maps |

### `map.*`

World-map zone control. Every action takes the required `zone_id=ZoneId` parameter; a missing value is logged and the action does nothing. Overrides are stored by the map service and persisted in the save.

| Action | Parameters | Description |
|---|---|---|
| `map.open_zone` | `zone_id=Z` | Open a zone for travel (counterpart to `"locked": true` in the map JSON) |
| `map.close_zone` | `zone_id=Z` | Close a zone — the marker stays but travel is blocked |
| `map.show_zone` | `zone_id=Z` | Reveal a hidden zone on the map |
| `map.hide_zone` | `zone_id=Z` | Remove a zone from the map entirely |

## Adding a custom action

Define an action class in your mod's `src/`:

```csharp
public class OpenTreasureChestAction : IScenarioAction
{
    public string Name => "treasure.open";

    public void Run(IActionContext ctx)
    {
        var item = ctx.GetParam("item");
        var count = ctx.GetParam("count");
        ctx.Log("SCENARIO", $"Gave player {count}x {item}");
    }
}
```

`IActionContext` exposes `GetParam(string)` for reading action parameters plus the game subsystem services and `Log`/`LogWarning` methods (see `Core/Actions/IActionContext.cs`). There is no typed getter such as `GetParamInt` — parse values yourself.

### Registration

You do **not** need to register actions manually: every implementation of `IScenarioAction` in your mod's compiled assembly is discovered automatically by reflection when the assembly is loaded.

If you need to register an assembly manually (outside the standard mod loading flow), call:

```csharp
ScenarioActionFactory.Instance.RegisterAssembly(assembly);
```

If two actions register the same `Name`, the earliest registration wins and later duplicates are silently ignored.

Then use in scenarios:

```scenario
action treasure.open item=food/ration count=5
```

### From GDScript (no Roslyn needed)

Create `scripts/my_actions.gd`:

```gdscript
extends RefCounted

func get_name():
    return "my.quest_activate"

func run(ctx):
    var questId = ctx.GetParam("quest_id")
    ctx.Quests.SetStatus(questId, "Progress")
    ctx.Quests.SendNotification(questId)
    ctx.Log("EVENT", "Quest " + questId + " activated via GDScript")
```

Declare in `manifest.json`:

```jsonc
{
  "name": "My GDScript Mod",                               // Human-readable mod name
  "gd_sources": ["scripts/*.gd"]                           // Glob patterns for .gd files — auto-discovered for actions, conditions, etc.
}
```

The kernel auto-discovers `get_name()` + `run()` and registers the action — no factory or provider code needed. The `ctx` parameter provides access to all game subsystems (Quests, Character, Battle, Inventory, etc.). See GDScript in Mods for the full action context API.

===== DOC docs/mods/scenes/scenarios/dsl/index.md: DSL Command Reference =====
Complete reference to the Scenario DSL commands: 29 keywords registered in the command strategy registry, plus `select`/`option`, which are handled specially by the parser (31 commands total, grouped by category).

---

## Flow Control

### `select`
Opens a choice menu. String is the prompt text (can be a locale key or literal).

```scenario
select "What now?" key=choice/prompt
option "Go home" key=choice/home -> :home
option "Explore" key=choice/explore -> :explore
```

- Must be followed by one or more `option` commands
- The `key=` parameter references a locale key for localization
- If no `key=`, the text itself is displayed
- Supports the same `first=`/`second=` avatar parameters as `text`

A `select` with no following `option` commands is silently dropped from the queue — no error is raised.

### `option`
Defines a choice within a `select` block. Three ways to specify the jump target:

```scenario
# Arrow form
option "Leave" key=rooms/kitchen/opt_exit -> :exit

# Named parameter form
option "Leave" key=rooms/kitchen/opt_exit goto=exit

# Bare positional label form
option "Leave" :exit
```

- Text can be a literal or a `key=` locale reference
- All three target forms are equivalent; the `->` arrow takes priority when several are present
- An `option` outside a `select` block is ignored

### `:label`
Anchor point for jumps. Must start with `:` at the beginning of a line.

```scenario
:enter_room
background "town/meetingmurakami/00079"
text "[Marao stepped inside]" key=enter/room
```

Labels are local to the scenario file. They cannot be jumped to from other scenarios.

### `goto`
Unconditional jump to a label.

```scenario
goto -> :cleanup
```

or equivalently:

```scenario
goto target=cleanup
```

> **Note:** The bare form `goto :cleanup` is **not** supported — it is silently ignored by the parser. Always use the `->` arrow or the `target=` parameter.

Useful for skipping optional content or implementing complex branching without nested options.

### `end`
Terminates the scenario script. Required at the end of every execution path.

```scenario
end
```

---

## Scene & Visuals

### `background`
Sets or changes the background image.

```scenario
background "town/meetingmurakami/00002-1920617752"
```

Path is relative to the mod's `backgrounds/` folder. No `.png` extension.

### `background_color`
Sets a solid color background (overrides image).

```scenario
background_color "#1a1a2e"
```

Accepts an HTML hex color (`#1a1a2e`) or a named color (`black`, `white`, `red`, `green`, `blue`, `yellow`, `gray`/`grey`). Anything else falls back to black. Useful for transitions, flashbacks, or UI overlays.

### `background_show_hide`
Animates the background layer in or out with a fade.

```scenario
background_show_hide hide
background_show_hide show duration=0.5 wait=true
```

| Parameter | Description |
|---|---|
| (positional) | `show` or `hide` (defaults to `show`; any other value is treated as `hide`) |
| `duration=` | Fade duration in seconds (default `1`) |
| `wait=` | `wait=true` pauses the script until the fade animation completes |

### `switch_scene`
Transitions to a different scene.

```scenario
switch_scene home_vestibule
switch_scene Battle
```

The target is a scene name defined in `scenes/{name}.json`.

| Parameter | Description |
|---|---|
| (positional) | Scene name to switch to |
| `speed=` | Transition speed as a float (default `4`) |
| `hidden=` | `hidden=true` transitions showing only the scene background |

The names `mainmenu` and `menu` are built-in: they are routed to the engine's native Godot scenes instead of mod-defined scenes. Any other name may be a scenario scene or a scene route registered by a mod through `SceneRouteRegistry` — for example `battle` is registered by `turn_battle_base` (fade transition plus combat music), so the core does not hardcode it.

### `foreground`
Sets or clears the foreground layer image (rendered above characters).

```scenario
foreground "effects/rain"
foreground ""
```

### `effect`
Plays a visual effect by ID. The syntax is `effect <effect_id>`:

```scenario
effect rain_overlay
```

The ID references a Godot scene (`.tscn`/`.scn`) from the mod's `effects/` resource group, resolved via the effect factory and instantiated above the scene. The script continues immediately — the effect plays independently and persists until its own lifetime ends.

> **Note:** Effect scenes are only loadable from **folder mods** — zip mods skip them with a warning, because `PackedScene` cannot be reconstructed from raw bytes in managed code.

---

## Text & Dialogue

### `text`
Displays a dialogue line with optional avatar portraits.

```scenario
text "- Dear lady!" key=meeting/greeting first=marao/think second=murakami/fear_1
```

| Parameter | Description |
|---|---|
| `key=` | Locale key for the text content |
| `first=` | Left-side avatar: `{character}/{expression}` |
| `second=` | Right-side avatar |
| `hide_color=` | `hide_color=true` shows avatars in shader-based silhouette mode (color suppressed) — useful for flashbacks or unknown speakers |
| `wait=` | `wait=false` advances the script immediately without waiting for a click |
| (inline) | If no `key=`, the text is displayed literally |

### `ptext`
Like `text`, but enables runtime `${var}` variable substitution in the text content.

```scenario
set player_name Marao
ptext "Well met, ${player_name}!"
```

The dialogue box is still shown, and `first=`/`second=` avatar parameters work the same as in `text`. Use `ptext` whenever you need to interpolate variables into a line; plain `text` does not substitute variables. Variables that are not set resolve to `?`.

| Parameter | Description |
|---|---|
| `key=` | Locale key for the text content (substitution still applies after lookup) |
| `first=` | Left-side avatar: `{character}/{expression}` |
| `second=` | Right-side avatar |
| `hide_color=` | `hide_color=true` shows avatars in shader-based silhouette mode |
| `wait=` | `wait=false` advances the script immediately without waiting for a click |
| (inline) | If no `key=`, the text is displayed literally (with `${var}` substitution) |

### `title`
Displays a large centered title, often used for chapter/section headers.

```scenario
title "Chapter 1 — The Hangover"
```

The text can be a literal or a `key=` locale reference; lines without resolvable text are silently skipped.

---

## Audio

### `music`
Switches the background music playlist.

```scenario
music playlists/tavern
music playlists/battle
```

Target is a playlist file in `music/playlists/`.

### `music_play`
Resumes paused music.

```scenario
music_play
```

### `music_pause`
Pauses the current music track.

```scenario
music_pause
```

### `sound`
Plays a one-shot sound effect.

```scenario
sound ui/click
sound battle/hit
```

Path is relative to the mod's `sounds/` folder. No `.ogg` extension.

---

## Lighting

### `light_set`
Configures dynamic 2D light sources. Accepts a JSON array of light objects.

```scenario
light_set [{"x":0.43,"y":0.58,"radius":500,"influence":1,"color":"#ffff00"}]
```

| Field | Description |
|---|---|
| `x`, `y` | Normalized position (0.0–1.0, relative to the viewport) |
| `radius` | Light radius in pixels |
| `influence` | Intensity multiplier (0.0–1.0) |
| `color` | Hex color (`#ffff00`) |

Multiple lights in one array:

```scenario
light_set [
  {"x":0.84,"y":0.04,"radius":600,"influence":1,"color":"#ffff00"},
  {"x":0.29,"y":0.16,"radius":600,"influence":1,"color":"#ffff00"}
]
```

At most 10 static lights are applied (plus the player's lamp); extra array entries are ignored.

Call with no arguments to reset all lights:

```scenario
light_set
```

### `light_use_time`
Enables/disables time-of-day influence on light color and angle.

```scenario
light_use_time true
light_use_time false
```

When enabled, the shader system adjusts warm/cool tones based on in-game time.

---

### `delay`
Pauses script execution for N seconds.

```scenario
delay 2.5
text "..."
delay 1.0
text "Oh."
```

Extended forms:

```scenario
# Invisible pause — no dialogue box activity
delay 1.5 hidden

# Pause with a caption shown during the wait
delay 3.0 "Meanwhile, across town..."

# Same, but the caption is resolved from a locale key
delay 3.0 "..." key=scene/meanwhile_caption
```

- The duration defaults to `1` second if omitted
- `hidden` can also be given as a named parameter: `delay 1.5 hidden=true`
- `key=` only applies when a text to display was given

### `animation`
Plays a sprite animation. Supports two forms:

Single sprite base with frame controls:

```scenario
animation marao/walk start=0 count=6 speed=8.0 loop=true
```

Multiple explicit sprites:

```scenario
animation sprites=town/rain_1,town/rain_2,town/rain_3 speed=4.0 loop=true
```

| Parameter | Description |
|---|---|
| (positional) | Sprite base path (first form only) |
| `sprites=` | Comma-separated sprite list (alternative to positional base) |
| `start=` | First frame index (default `0`, first form only) |
| `count=` | Number of frames to play (default `1`) |
| `speed=` | Playback speed as a float multiplier (default `1.0`) |
| `loop=` | `true`/`false` — loop the animation (defaults to `true` when omitted) |

There is no `duration=` parameter — control timing via `speed=` and `count=`.

---

## Interactive Objects

### `interactive_add`
Adds a clickable interactive object to the scene.

```scenario
interactive_add kitchen_lamp
```

The name references an interactive definition in `interactives/{name}.json`.

### `interactive_remove`
Removes a specific interactive object.

```scenario
interactive_remove kitchen_lamp
```

### `interactive_clear`
Removes all interactive objects from the current scene.

```scenario
interactive_clear
```

### `interactive_set`
Enables, disables, shows, or hides a specific interactive object.

```scenario
interactive_set kitchen_lamp enabled=false
interactive_set cabinet_door visible=true
```

Supported named parameters: `enabled=true|false`, `visible=true|false`.

---

## Dialog Visibility

### `show_dialog`
Shows the dialogue box UI (if hidden).

```scenario
show_dialog
```

### `hide_dialog`
Hides the dialogue box UI without clearing text.

```scenario
hide_dialog
```

Used for cutscenes where you want the background visible without UI.

---

## Variables & Logic

### `set`
Stores a variable in the dialog runtime's variable store for later use — readable via `${var}` substitution in `ptext` lines and via `ctx.GetParam(...)` in actions.

```scenario
set met_murakami true
set door_locked false
```

The syntax is **space-separated**: first token is the name, second token is the value. Do not write `set name = value` with spaced equals — the parser stores the literal `"="` as the value. A `name=value` token (no spaces around `=`) is treated as a named argument, which is a different mechanism and not how `set` reads its operands.

### `empty`
A no-op command — it queues a placeholder action that does nothing and is skipped instantly.

```scenario
empty
```

Labels (`:label`) are implemented as named `empty` markers, so a bare `empty` line is also a convenient placeholder slot during development.

---

## Notifications

### `notify`
Queues a notification banner in the top-left corner of the screen. Notifications appear one by one, stack vertically, and auto-dismiss after 10 seconds. Use for quest updates, item pickups, or any non-blocking informational messages.

```scenario
notify "Quest updated: Find the key"
```

```scenario
notify "Received: Health Potion"
```

| Parameter | Description |
|---|---|
| (inline) | The notification text to display |
| `key=` | Locale key for localization (takes priority over inline text) |

Notifications are queued in a buffer — they appear in order and will wait if the screen already has the maximum number of visible notifications (4). The 10-second timer starts only when the notification becomes visible, not when it is queued.

---

## Special Syntax

### `#` — Comments

```scenario
# This line is ignored by the parser
# Use comments to document complex branching
```

### `---` — Metadata Separator

Separates the YAML-like metadata header from the script body. Must appear on its own line.

### `->` — Jump Operator

Used in `option` and `goto` commands to point to `:label` targets.

```scenario
option "Go inside" -> :enter
```

### `${path}` — Include Directive

Inside `.scenario` scripts and other mod text files, `${ path/to/file }` is replaced with the full content of the referenced file at **mod resolution time** — before the script is ever parsed. This happens while the mod loader reads the file (the same mechanism used by `ScenarioResolver` and the other text-based resolvers).

```scenario
# dialogue/intro.scenario
${ events/common_ambience.txt }
background "town/meetingmurakami/00079"
```

- Includes are resolved relative to the mod root and may be nested (an included file can contain includes of its own)
- If the path does not resolve and `path + ".json"` exists, that file is used instead
- Circular includes are detected and reported as errors; resolution fails for the affected file
- Whitespace inside the braces is allowed (`${ path }`)

Do not confuse this with the runtime `${var}` substitution in dialogue text (see `ptext`): includes are expanded once when the mod loads, variables are substituted every time the line is shown.

===== DOC docs/mods/scenes/scenarios/dsl/conditions/index.md: Conditions — Quest Expression Language =====
The `condition:` metadata field uses a simple expression language to control which scenarios execute based on quest state and scene flags. Boolean literals `true` and `false` are also valid condition expressions.

## Syntax

```scenario
condition: QuestId.method("arg") [&& || ! expr ...]
```

## Available methods

| Method | Description |
|---|---|
| `hasTag("tag")` | True if the quest has the specified tag |
| `notHasTag("tag")` | True if the quest does NOT have the tag |
| `IsActive` | True when quest Status equals `Progress` |
| `Status <op> N` | Quest status compared against number `N` |
| `DataStatus <op> N` | Quest numeric counter compared against number `N` |

Supported comparison operators (for both quest `Status`/`DataStatus` and flag comparisons): `==`, `!=`, `>`, `>=`, `<`, `<=`.

## Custom GDScript conditions

Mods can add custom condition names via GDScript. A GDScript with `get_condition_name()` + `evaluate(flags, args)` is auto-registered into the condition parser.

`scripts/weather_conditions.gd`:
```gdscript
extends RefCounted

func get_condition_name():
    return "gds.is_rain"

func evaluate(flags, args):
    return flags.get("weather", "") == "rain"
```

Usage in scenario:
```scenario
condition: gds.is_rain
```

With arguments:
```gdscript
func get_condition_name():
    return "gds.flag_equals"

func evaluate(flags, args):
    var expected = args.get("0", "")
    return flags.get("custom_flag", "") == expected
```

```scenario
condition: gds.flag_equals("victory")
```

GDScript conditions take priority over the built-in `QuestParser` — so `gds.is_rain` is resolved as a condition, not as a quest expression. See GDScript in Mods for details.

## Flag conditions

Scene flags are evaluated directly — no `scene.` prefix needed:

```scenario
condition: door_unlocked == "true"
condition: counter >= 3
```

> **Important:** these flags are **not** fed by the DSL `set` command. `set` writes variables into the dialog runtime store, which are consumed by `${var}` substitution and `ctx.GetParam(...)` in actions — a separate mechanism. The flag dictionary used here is populated via `ScenarioCondition.SetFlag`, which currently has no production callers. Do not expect `set my_flag value` to make `condition: my_flag == "value"` pass.

## Operators

| Operator | Description |
|---|---|
| `==`, `!=`, `>`, `>=`, `<`, `<=` | Comparison operators — valid for quest `Status`/`DataStatus` and flag comparisons |
| `&&` | Logical AND — both sides must be true |
| `\|\|` | Logical OR — at least one side must be true |
| `!` | Logical NOT — negates the following expression |
| `()` | Grouping for precedence |
| `true` / `false` | Boolean literals |

## Real example — Murakami quest chain

```scenario
scene: town_entry
priority: 20
condition: MeetingMurakamiQuest.hasTag("wolf_battle") && MeetingMurakamiQuest.notHasTag("boss_battle")
---
background "town/meetingmurakami/00002-1920617752"
text "[Wolf tracks lead straight to the doorstep]" key=meeting/after_wolf/tracks
# ... scenario continues ...
```

This scenario only fires **after** the player fought the wolf but **before** they fought the boss. It's the middle chapter of a three-part quest.

## More complex examples

### Multiple quest dependencies

```scenario
condition: AwakeningQuest.hasTag("met_marao") && MeetingMurakamiQuest.hasTag("recruited")
```

Both conditions must be met.

### Either/or branching

```scenario
condition: AwakeningQuest.hasTag("found_book") || AwakeningQuest.hasTag("found_map")
```

Runs if the player found either the book OR the map.

### Negation with OR

```scenario
condition: !AwakeningQuest.hasTag("tutorial_done") && (AwakeningQuest.hasTag("woke_up") || AwakeningQuest.hasTag("in_alley"))
```

The tutorial hasn't been done yet, but the player has woken up or is in the alley.

### Status-based progression

```scenario
condition: AwakeningQuest.Status >= 2
```

Runs when AwakeningQuest reaches status 2 or higher.

### Using DataStatus for numeric progress

```scenario
condition: MainQuest.DataStatus >= 10
```

Runs when the quest's numeric progress counter reaches 10 or more.

### Combining quest conditions with flags

```scenario
condition: AwakeningQuest.hasTag("met_marao") && boss_battle == "done"
```

Runs when the prolog tag is set AND the scene flag `boss_battle` equals `"done"`.

## Condition evaluation order

1. Engine collects all scenarios for the current scene
2. Sorts by `priority` (ascending) and resolves `after:` dependencies
3. Evaluates each scenario's `condition:` in that order; a scenario without a condition always passes
4. **All** scenarios whose condition passes are enqueued into the event chain and run one after another — there is no first-match cut-off

This means:
- Conditions filter scenarios out of the chain entirely; a passing scenario never blocks later scenarios from running
- Priority only determines execution order among passing scenarios

```mermaid
flowchart LR
    A["priority: 5<br/>condition: hasTag<br/>secret_found"] -->|"passes"| S["Event Chain"]
    B["priority: 8<br/>condition: hasTag<br/>chapter_2"] -->|"fails"| X["Skipped"]
    C["priority: 10<br/>condition: none"] -->|"always passes"| S
    S --> SCENE["Run scenarios in priority order"]
```

===== DOC docs/mods/scenes/tscn-runtime/index.md: Runtime TSCN Scene Loading =====
Godot `.tscn` scene files can be loaded and instantiated at runtime — after the game is built — through a custom parser and builder. This is the primary mechanism for mods to ship pre-authored Godot scenes that are constructed identically to how Godot itself would build them.

## Why a custom parser?

Godot's native `PackedScene` loading (`ResourceLoader.Load<PackedScene>(path)`) requires the scene to be present as a file on disk. Mods distributed as `.zip` archives cannot use this path. The custom parser reads `.tscn` text through `IModFileProvider` — the same abstract file backend that serves all other mod assets (textures, scenarios, JSON). This means scenes work identically whether the mod is a loose folder or a compressed archive.

## Architecture

```mermaid
flowchart LR
    Lexer["TscnLexer<br/>(tokenizer)"] --> Parser["TscnParser<br/>(orchestrator)"]
    Parser --> Model["ParsedScene<br/>(model)"]
    Model --> Builder["SceneBuilder<br/>(builder)"]
    Builder --> NodeTree["Node tree"]

    Parser --> SecReg["SectionParser<br/>Registry"]
    Builder --> PropReg["PropertyResolver<br/>Registry"]

    SecReg --> GdScene["[gd_scene]<br/>Parser"]
    SecReg --> ExtRes["[ext_resource]<br/>Parser"]
    SecReg --> NodeParser["[node]<br/>Parser"]

    PropReg --> ScriptSkip["script=<br/>skip"]
    PropReg --> ExtResProp["ExtResource<br/>→ PackedScene"]
    PropReg --> SubResProp["SubResource<br/>→ Resource"]
    PropReg --> ThemeOverride["theme_override_*<br/>→ AddTheme*Override"]
    PropReg --> Default["default<br/>→ ToVariant"]
```

Both `SectionParserRegistry` and `PropertyResolverRegistry` use `Reflections.FindAndCreate<T>()` auto-discovery — any assembly can register new section or property handlers without modifying existing code.

## Loading pipeline

```mermaid
sequenceDiagram
    participant Caller
    participant Lexer as TscnLexer
    participant Parser as TscnParser
    participant SecReg as SectionParserRegistry
    participant Model as ParsedScene
    participant Builder as SceneBuilder
    participant PropReg as PropertyResolverRegistry
    participant NodeTree as Node tree

    Caller->>Lexer: Tokenize(.tscn text)
    Lexer-->>Caller: List of TscnToken (SectionHeader | Property)

    Caller->>Parser: Parse(source)
    loop For each token
        alt SectionHeader
            Parser->>SecReg: Get(sectionName)
            SecReg-->>Parser: ISectionParser
            Parser->>Parser: Open(headerAttrs)
        else Property
            Parser->>Parser: Property(key, rawValue)
        end
    end
    Parser-->>Caller: ParsedScene

    Caller->>Builder: Build(parsedScene, instanceLoader, resourceResolver)
    Builder->>Builder: BuildAll sub-resources
    loop For each ParsedNode
        Builder->>Builder: CreateNode + AddChild
        loop For each property
            Builder->>PropReg: Find(key, value)
            PropReg-->>Builder: IPropertyResolver.Apply()
        end
    end
    alt recursive PackedScene ref
        Builder->>Caller: instanceLoader(path) → recursive parse+Build
    end
    Builder->>NodeTree: Connect signals, call OnSceneBuilt
    Builder-->>Caller: Root Node
```

## TSCN format reference

### `[gd_scene ...]`

```ini
[gd_scene format=3]                    ; uid= and load_steps= ignored
```

### `[ext_resource ...]`

```ini
[ext_resource type="Script" path="scripts/MyScript.cs" id="1_script"]
[ext_resource type="PackedScene" path="scenes/dialog.tscn" id="2_dialog"]
```

| Attribute | Required | Notes |
|---|---|---|
| `type` | Yes | `"Script"` and `"PackedScene"` drive node creation and recursive instancing; other types (e.g. `"Texture2D"`) resolve through the ext-resource builder registry |
| `path` | Yes | Mod-relative path. Resolved through mod priority system across all loaded mods. The editor's on-disk form (`res://mods/<mod>/...`) is normalised to the same mod-relative key, so editor-authored scenes work unchanged |
| `id` | Yes | Local identifier within this scene file |
| `uid` | No | Ignored (build-time identifier, inaccessible after build) |

### `[node ...]`

```ini
[node name="Root" type="Control" groups=["my_group"]]
anchor_right = 1.0
script = ExtResource("1_script")

[node name="Child" parent="." instance=ExtResource("2_dialog")]
visible = false

[node name="DeepChild" parent="Root/Panel" type="Label"]
text = "Hello"
theme_override_font_sizes/font_size = 14        ; applied via AddThemeFontSizeOverride
metadata/_tab_index = 0                          ; editor metadata — IGNORED
```

| Attribute | Required | Notes |
|---|---|---|
| `name` | Yes | Node name |
| `type` | No | Godot class name (`Control`, `Button`, `Label`, etc.). Not needed when `instance=` is present |
| `parent` | No | `"."` = root node; `"Name"` or `"Name/Child"` = explicit parent. Omitted for root node |
| `instance` | No | `ExtResource("id")` — recursively parses and builds the referenced `.tscn` |
| `script` | No | `ExtResource("id")` — resolves to C# type via mod priority system, creates node via `Activator.CreateInstance(type)` |
| `groups` | No | `["group1", "group2"]` — adds node to Godot groups |
| `node_paths` | No | `PackedStringArray("var1", "var2")` — declares which properties are NodePath editor bindings |
| `unique_id` | No | Ignored (editor-only annotation) |

### `[sub_resource ...]`

```ini
[sub_resource type="StyleBoxFlat" id="style_1"]
bg_color = Color(0.1, 0.2, 0.3, 1)
corner_radius_top_left = 4
```

Built-in builders support: `StyleBoxFlat`, `StyleBoxEmpty`, `ShaderMaterial`, `ImageTexture`, `Gradient`, `GradientTexture1D`, `Curve`, `FontFile` (and `Font` alias).

Custom types are added by implementing `ISubResourceBuilder` — auto-discovered via `Reflections.FindAndCreate<T>()`.

### `[connection ...]`

```ini
[connection signal="pressed" from="MyButton" to="." method="OnPressed" flags=0]
```

| Attribute | Required | Notes |
|---|---|---|
| `signal` | Yes | Signal name on the source node |
| `from` | Yes | Source node name (relative to root) |
| `to` | Yes | Target node — **only `"."` (root) connections are applied**. Connections to animation players, visual effects, etc. are skipped |
| `method` | Yes | Method name on the target node |
| `flags` | No | Godot connect flags (default 0) |
| `binds` | No | Not supported |
| `unbinds` | No | Not supported |

## Entry point system

Any root node can declare an entry-point class via a special property:

```ini
[node name="MyScene" type="Control"]
__entry_point = "MyMod.EntryPointClass"
```

The class must implement `ITscnSceneEntryPoint`:

```csharp
public interface ITscnSceneEntryPoint
{
    void OnSceneBuilt(Node sceneRoot);
}
```

The entry point is resolved across all loaded assemblies — mod-compiled code first, then `Type.GetType()`, then all `AppDomain` assemblies as fallback. `OnSceneBuilt` is called after the entire node tree is constructed, properties are applied, and signals are connected.

## PackedScene resolution

When a node has `instance=ExtResource("id")`, the parser:

1. Looks up the `ext_resource` by id to find the `.tscn` path
2. Searches all mods in priority order for the file (same as script resolution)
3. Recursively parses and builds the sub-scene
4. Applies any override properties from the parent node (`visible = false` etc.)

```ini
; Parent scene
[ext_resource type="PackedScene" path="scenes/dialog.tscn" id="2_dialog"]
[node name="Dialog" parent="." instance=ExtResource("2_dialog")]
visible = false                    ; ← overwrites dialog.tscn root visibility
```

When a node property references a PackedScene (not via `instance=` but as an exported field):

```ini
answerBoxScene = ExtResource("2_answerbox")
```

The builder resolves it recursively, packs the result into a `PackedScene` via `PackedScene.Pack()`, and sets the property. This enables scripts with `[Export] PackedScene` fields to work identically to Godot's native loading.

## Mod priority resolution

Both script paths and PackedScene paths are resolved through the mod priority system:

```
.tscn: path="scripts/MyScript.cs"
   → ScriptTypeRegistry: search all mods in load-order priority
   → First mod where the file exists (or the type is registered) wins
```

This is the same mechanism used by `ModRegistry` for asset deduplication. A mod with higher priority can override a scene dependency from another mod.

## Property value types supported

| TSCN value | Parsed output | Godot type (build time) |
|---|---|---|
| `null` | `null` | `Variant` null |
| `true` / `false` | `bool` | `bool` |
| `42` | `int` | `int` |
| `1.0` | `float` | `float` |
| `"text"` | `string` | `string` |
| `NodePath("path")` | `ParsedGodotValue("NodePath", "path")` | `Godot.NodePath` |
| `StringName("name")` | `ParsedGodotValue("StringName", "name")` | `Godot.StringName` |
| `Vector2(1, 2)` | `ParsedGodotValue("Vector2", 1f, 2f)` | `Godot.Vector2` |
| `Vector2i(1, 2)` | `ParsedGodotValue("Vector2i", 1f, 2f)` | `Godot.Vector2I` |
| `Vector3(1, 2, 3)` | `ParsedGodotValue("Vector3", 1f, 2f, 3f)` | `Godot.Vector3` |
| `Vector3i(1, 2, 3)` | `ParsedGodotValue("Vector3i", 1f, 2f, 3f)` | `Godot.Vector3I` |
| `Vector4(1, 2, 3, 4)` | `ParsedGodotValue("Vector4", 1f, 2f, 3f, 4f)` | `Godot.Vector4` |
| `Vector4i(1, 2, 3, 4)` | `ParsedGodotValue("Vector4i", 1f, 2f, 3f, 4f)` | `Godot.Vector4I` |
| `Color(0, 0, 0, 1)` | `ParsedGodotValue("Color", 0f, 0f, 0f, 1f)` | `Godot.Color` |
| `Rect2(0, 0, 100, 200)` | `ParsedGodotValue("Rect2", 0f, 0f, 100f, 200f)` | `Godot.Rect2` |
| `Rect2i(0, 0, 100, 200)` | `ParsedGodotValue("Rect2i", 0f, 0f, 100f, 200f)` | `Godot.Rect2I` |
| `Transform2D(1,0,0,1,0,0)` | `ParsedGodotValue("Transform2D", ...)` | `Godot.Transform2D` |
| `Plane(0, 0, 0, 0)` | `ParsedGodotValue("Plane", 0f, 0f, 0f, 0f)` | `Godot.Plane` |
| `Quaternion(0, 0, 0, 1)` | `ParsedGodotValue("Quaternion", 0f, 0f, 0f, 1f)` | `Godot.Quaternion` |
| `AABB(0, 0, 0, 1, 1, 1)` | `ParsedGodotValue("AABB", 0f, 0f, 0f, 1f, 1f, 1f)` | `Godot.Aabb` |
| `ExtResource("id")` | `TscnPropertyValue(ExtResource)` | Resolved at build time |
| `SubResource("id")` | `TscnPropertyValue(SubResource)` | Resolved at build time |
| `PackedStringArray("a","b")` | `string[]` | `Godot.Collections.Array<string>` |
| `PackedInt32Array(1, 2)` | `int[]` | `Godot.Collections.Array<int>` |
| `PackedInt64Array(1, 2)` | `long[]` | `Godot.Collections.Array<long>` |
| `PackedFloat32Array(1.5, 2.5)` | `float[]` | `Godot.Collections.Array<float>` |
| `PackedFloat64Array(1.5, 2.5)` | `double[]` | `Godot.Collections.Array<double>` |
| `PackedByteArray(0, 255)` | `byte[]` | `Godot.Collections.Array<byte>` |
| `PackedVector2Array(1, 2, 3, 4)` | `ParsedGodotValue[]` | `Godot.Collections.Array<Vector2>` |
| `PackedVector3Array(1, 2, 3, 4, 5, 6)` | `ParsedGodotValue[]` | `Godot.Collections.Array<Vector3>` |
| `PackedColorArray(1, 0, 0, 1, 0, 1, 0, 1)` | `ParsedGodotValue[]` | `Godot.Collections.Array<Color>` |

Godot native objects are deliberately **not** created during parsing — `TscnPropertyParser` returns lightweight `ParsedGodotValue` wrappers. Conversion to actual `Godot.Variant` happens only in `SubResourceFactory.ToVariant()` at build time, inside the Godot runtime.

## What is intentionally NOT supported

| Feature | Reason |
|---|---|
| `uid="..."` on all sections | Build-time identifiers; mod scenes are not preprocessed by Godot |
| `load_steps=N` | Editor metadata |
| `unique_id=N` on nodes | Editor annotation for scene diffing |
| `[editable ...]` | Editor-only placeholder for inherited scenes |
| `theme_override_*` (nested properties like `theme_override_constants/separation`) | Applied for plain values through `AddTheme*Override`; overrides whose value is an `ExtResource(...)`/`SubResource(...)` reference are not applied |
| `metadata/*` | Editor metadata only |
| `connection` with `to != "."` | Non-script connections (animation callbacks, visual tweens) |
| `binds=` / `unbinds=` on connections | Not used in any scene in this project |
| `owner=` on nodes | Editor concept irrelevant for mod-constructed scenes |
| `format=2` (Godot 3.x scenes) | Project targets Godot 4.x |
| Binary `.scn` format | Only text `.tscn` is supported |

## Adding a scene to a mod

1. Create `scenes/my_scene.tscn` in your mod directory
2. Use standard Godot editor to author the scene (the same file works in both editor and runtime)
3. Set `__entry_point = "MyMod.EntryClass"` on the root node
4. Implement `ITscnSceneEntryPoint` in your mod's C# code:

```csharp
using Cthangover.Core.Scenes;
using Godot;

namespace MyMod
{
    public class EntryClass : Node, ITscnSceneEntryPoint
    {
        public void OnSceneBuilt(Node sceneRoot)
        {
            var button = sceneRoot.GetNodeOrNull<Button>("StartButton");
            button?.Connect("pressed", Callable.From(OnStart));
        }

        private void OnStart() { /* ... */ }
    }
}
```

5. In your mod code, load and build:

```csharp
var sceneNode = TscnScenes.LoadAndBuild("scenes/my_scene.tscn");
GetParent().AddChild(sceneNode);
```

`TscnScenes.LoadAndBuild(string scenePath)` takes a single argument — the mod-relative scene path. There is no mod ID parameter: the resolver locates the owning mod by looking up which loaded mod contains that file, walking mods in load-order priority.

## Extending the parser

### Adding a new section type

Implement `ISectionParser`:

```csharp
public class MyCustomSectionParser : ISectionParser
{
    public string SectionType => "my_custom";

    public void Open(Dictionary<string, string> headerAttrs) { /* parse header */ }
    public void Property(string key, string rawValue) { /* handle properties */ }
    public void Close(ParsedScene scene) { /* commit results */ }
}
```

Auto-discovered. No registration code needed.

### Adding a new property handler

Implement `IPropertyResolver`:

```csharp
public class MyPropResolver : IPropertyResolver
{
    public bool CanResolve(string key, object value)
        => value is ParsedGodotValue pgv && pgv.TypeName == "MyType";

    public void Apply(Node node, string key, object value, PropertyContext ctx)
        => node.Set(key, /* my logic */);
}
```

### Adding a new sub-resource builder

Implement `ISubResourceBuilder`:

```csharp
public class MyResourceBuilder : ISubResourceBuilder
{
    public string ResourceType => "MyResourceType";

    public Resource Build(SubResourceDef def, SubResourceBuildContext ctx)
        => /* create and configure */;
}
```

===== DOC docs/mods/settings/index.md: Mod Settings =====
Mods can expose user-configurable settings that appear in the in-game settings menu under a dedicated "Mod Settings" tab. The UI shows a vertical list of mods (only those that have settings) on the left; clicking a mod name displays its settings controls on the right.

## Architecture

The system mirrors the save system (`IModSaveable`) using a similar dispatch pattern:

```
Mod assembly loaded
  → ModSettingsRegistry.RegisterAssembly(assembly, modId)
    → Finds IModSettings implementation via reflection
    → Stores (modId, instance) pair

Boot time:
  → ModSettingsRegistry.LoadAll()
    → For each registered mod: load user://mod_settings/{modId}.json
    → Call mod.ReadValues(blob)

Settings menu opened:
  → ModSettingsRegistry.GetModsWithSettings()
    → Returns sorted list of mod IDs (only mods with IModSettings)
  → BuildModList() — creates buttons in left panel
  → On click: BuildModSettingsPanel(modId)
    → mod.GetDefinitions() → list of ModSettingDefinition
    → Creates UI controls (CheckButton, SpinBox, OptionButton, etc.)
    → mod.WriteValues(blob) → populates controls with current values

Setting changed:
  → Read all control values into DataBlob
  → ModSettingsRegistry.SaveModSettings(modId, blob)
  → ModSettingsRegistry.ApplyValues(modId, blob)
    → mod.ReadValues(blob) — immediate apply

Reset:
  → ModSettingsRegistry.DeleteModSettings(modId)
  → Rebuild panel with DefaultValues from definitions
```

## Core concepts

### IModSettings interface

Mods implement one class implementing `IModSettings`. It is auto-discovered when the mod assembly loads.

```csharp
public interface IModSettings
{
    IReadOnlyList<ModSettingDefinition> GetDefinitions();
    void WriteValues(DataBlob blob);
    void ReadValues(DataBlob blob);
}
```

- **GetDefinitions()** — Declares which settings the mod has. Called every time the settings panel opens.
- **WriteValues(blob)** — The mod writes its current values into the blob. Called to populate UI controls and to persist.
- **ReadValues(blob)** — The mod reads values from the blob and applies them. Called on boot (from persisted data) and on every UI change.

### ModSettingDefinition

| Field | Type | Description |
|---|---|---|
| `Key` | `string` | Unique key within this mod (e.g. `"atb_speed"`) |
| `Name` | `string` | Localisation key shown as label (e.g. `"mod_ffbattle/atb_speed"`) |
| `Type` | `SettingType` | Widget type: `Bool`, `Integer`, `Float`, `String`, `Enum`, `Slider` |
| `DefaultValue` | `string` | String representation of the default. Used when no persisted value exists and on Reset |
| `Options` | `Dict<string,string>` | For `Enum` only: maps internal values to localisation keys |
| `Min` / `Max` / `Step`| `float` | For `Slider` only |

### Setting types and UI controls

| Type | Godot control | Description |
|---|---|---|
| `Bool` | `CheckButton` | On/off toggle |
| `Integer` | `SpinBox` (step=1) | Whole-number input |
| `Float` | `SpinBox` (step=0.1) | Decimal input |
| `String` | `LineEdit` | Free-form text input |
| `Enum` | `OptionButton` | Dropdown, populated from `Options` dictionary |
| `Slider` | `HSlider` + value label | Range slider with configurable min/max/step |

### Persistence

Each mod's settings are stored as a JSON-serialised `DataBlob` at `user://mod_settings/{modId}.json`. The file contains key-value pairs matching the mod's definitions.

The core handles all file I/O; the mod never touches the filesystem.

### Lifecycle

```
┌──────────────────────────────────────────────────────────────┐
│ Boot                                                         │
│   ModRegistry.Initialize()                                   │
│     → ModAssemblyLoader.RegisterAssembly()                   │
│       → ModSettingsRegistry.RegisterAssembly(asm, modId)     │
│         → Finds IModSettings, instantiates, stores           │
│   SceneManager.Initialize()                                  │
│     → ModInitializerRegistry.NotifyResourcesReady()          │
│     → ModSettingsRegistry.LoadAll()                          │
│       → For each mod: loads JSON, calls ReadValues()         │
└──────────────────────────────────────────────────────────────┘
                              │
┌─────────────────────────────▼────────────────────────────────┐
│ Settings menu opened                                         │
│   BuildModList()                                             │
│     → GetModsWithSettings() → buttons in list                │
│   Select first mod                                           │
│   BuildModSettingsPanel(modId)                               │
│     → GetDefinitions() → controls in right panel             │
│     → WriteValues(blob) → populate controls                  │
└──────────────────────────────────────────────────────────────┘
                              │
┌─────────────────────────────▼────────────────────────────────┐
│ User changes a setting                                       │
│   OnModSettingChanged()                                      │
│     → Read all controls → DataBlob                           │
│     → SaveModSettings(modId, blob) → disk                    │
│     → ApplyValues(modId, blob) → mod.ReadValues()            │
└──────────────────────────────────────────────────────────────┘
```

## Implementing IModSettings in a mod

### Minimal example — simple toggles and sliders

```csharp
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;

public class MyModSettings : IModSettings
{
    private float _explosionRadius = 1.5f;
    private bool _debugMode;

    public IReadOnlyList<ModSettingDefinition> GetDefinitions()
    {
        return new List<ModSettingDefinition>
        {
            new()
            {
                Key = "explosion_radius",
                Name = "mymod/explosion_radius",
                Type = SettingType.Slider,
                DefaultValue = "1.5",
                Min = 0.5f,
                Max = 10f,
                Step = 0.5f
            },
            new()
            {
                Key = "debug_mode",
                Name = "mymod/debug_mode",
                Type = SettingType.Bool,
                DefaultValue = "false"
            }
        };
    }

    public void WriteValues(DataBlob blob)
    {
        blob.SetFloat("explosion_radius", _explosionRadius);
        blob.SetBool("debug_mode", _debugMode);
    }

    public void ReadValues(DataBlob blob)
    {
        _explosionRadius = blob.GetFloat("explosion_radius", 1.5f);
        _debugMode = blob.GetBool("debug_mode", false);
    }
}
```

[image: Mod Settings example view]

### Example with enum

```csharp
new ModSettingDefinition
{
    Key = "difficulty",
    Name = "mymod/difficulty",
    Type = SettingType.Enum,
    DefaultValue = "normal",
    Options = new Dictionary<string, string>
    {
        { "easy",   "mymod/diff_easy" },
        { "normal", "mymod/diff_normal" },
        { "hard",   "mymod/diff_hard" }
    }
}
```

[image: Mod Settings example view]

The `Options` dictionary maps internal values (stored in the blob) to localisation keys (shown in the dropdown). The mod provides translations in its `locale/` files.

### Localisation

Every `Name` field and every value in `Options` is a localisation key resolved via `TranslationServer.Translate()`. Add entries to your mod's `locale/` files:

```
# locale/en.properties
mymod/explosion_radius = Explosion Radius
mymod/debug_mode = Debug Mode
mymod/difficulty = Difficulty
mymod/diff_easy = Easy
mymod/diff_normal = Normal
mymod/diff_hard = Hard
```

### Schema changes

**`DataBlob.Version` is NOT persisted for mod settings.** `ModSettingsRegistry.SaveModSettings` writes only the key-value payload (`blob.GetBytes()`), and on load the registry constructs a fresh `DataBlob(payload)`, which resets `Version` to its default of 1. (`Version` *is* persisted through the main save system's `SavePackage` — just not here.)

Detect schema changes by reading keys with defaults instead — missing keys resolve to your fallback values, so added fields need no migration logic:

```csharp
public void WriteValues(DataBlob blob)
{
    blob.SetFloat("explosion_radius", _explosionRadius);
    blob.SetFloat("knockback_force", _knockbackForce); // added later
}

public void ReadValues(DataBlob blob)
{
    _explosionRadius = blob.GetFloat("explosion_radius", 1.5f);
    _knockbackForce = blob.GetFloat("knockback_force", 3.0f); // default for old saves
}
```

## Important rules

- **One `IModSettings` per mod** — create a single implementation in your assembly.
- **Always provide `DefaultValue`** — used when no persisted value exists and on Reset.
- **Use `DataBlob` key-value API** — `GetInt/SetInt`, `GetFloat/SetFloat`, `GetString/SetString`, `GetBool/SetBool`.
- **Always call with defaults** — `blob.GetInt("key", defaultVal)` so missing keys don't break.
- **Key names are persistent** — changing a `Key` creates a new setting; the old value is lost.
- **Provide localisation** — add translations for all `Name` and `Options` values in `locale/`.
- **The `version` field in `manifest.json`** is used by the save system for validation; mod settings have no persisted schema version (`DataBlob.Version` is reset to 1 on load) — handle schema changes via read-with-defaults.
- **Declare `"sources"` in manifest.json** — your `.cs` files must be listed in the `sources` field of `manifest.json` for the compiler to pick them up. See Manifest — sources for details.

===== DOC docs/mods/shaders/index.md: Shaders =====
Godot `.gdshader` and `.gdshaderinclude` files that provide visual effects: pixel-art rendering, scene transitions, sprite-sheet animation, interactive highlighting, and noise generation.

## Directory structure

```filestree
shaders/
├── pixel_art.gdshader
├── pixel_art_post_process.gdshader
├── scene_transition.gdshader
├── timed_sprite.gdshader
├── interactive_highlight.gdshader
├── avatar.gdshader
├── default.gdshader
├── effect_anim.gdshader
└── noise_simplex.gdshaderinclude
```

## Real example shaders

From `mods/ui_base/shaders/`:

| Shader | Purpose |
|---|---|
| `pixel_art.gdshader` | Pixel-perfect rendering at low resolution, preserves sharp edges at any scale |
| `pixel_art_post_process.gdshader` | Full-screen post-process: applies pixel-art look to the entire viewport |
| `scene_transition.gdshader` | Animated transitions between scenes (fade, wipe, dissolve) |
| `timed_sprite.gdshader` | Sprite-sheet animation driven by shader uniforms (time-based frame selection) |
| `interactive_highlight.gdshader` | Glow/highlight effect when hovering over clickable objects |
| `avatar.gdshader` | Character portrait rendering with optional effects (emotion transitions, shadows) |
| `default.gdshader` | Base shader used for regular sprites and backgrounds |
| `effect_anim.gdshader` | Particle and effect animation driven by GPU |
| `noise_simplex.gdshaderinclude` | Simplex noise function — included by other shaders for procedural effects |

## How shaders are referenced

Shaders are referenced by filename (no extension) in the engine. They can be:

1. **Assigned to materials** in Godot scenes via the engine
2. **Overridden by mods** — if a mod provides a shader with the same name, it replaces the default
3. **Applied to specific nodes** via C# code, using the static `Shaders` facade
   (backed by `ShaderResolver`):

```csharp
var shader = Shaders.Resolve("pixel_art");
if (shader != null)
{
    var material = node.Material as ShaderMaterial;
    material.Shader = shader;
}
```

The `Shaders` facade exposes:
- `Collect()` — all `.gdshader` files across mods, keyed by logical name
- `Resolve(name)` — loads the `Shader` resource by name (`null` if not found)
- `TryGetCached(name)` — returns a previously resolved, still-valid instance

At runtime, `ShaderModReplacer.PatchScene(Node)` walks freshly instantiated
scenes and swaps every `ShaderMaterial` whose original resource path matches a
mod-provided shader name. The same pass also performs **texture overrides**:
`TextureRect` and `Sprite2D` nodes have their textures matched by resource
filename against the mod texture registry and replaced the same way — so
`PatchScene` is not limited to shaders.

## Engine-side consumers

The engine ships a dedicated `Control` node that consumes a mod-provided
shader: `PixelArtPostProcess` (`Core/Shaders/PixelArtPostProcess.cs`). On
`_Ready` it builds a full-viewport `ColorRect`, loads the
`pixel_art_post_process` shader via the mod system, and exposes an
`[Export] float PixelSize` property that drives the shader's `pixel_size`
uniform — adjusting it from the Godot inspector updates the effect live.

## Shipped core shaders

Besides the interface shaders listed above, the `cthangover_game` mod ships its own shader
family in `mods/cthangover_game/shaders/`: a set of `2d_cubism_*.gdshader` files
(`mask`, plus the blend modes `mask_mul` / `mask_add` / `mask_mix`, each blend mode
also in an `_inv` inverted variant, and `norm_mul` / `norm_add` / `norm_mix`)
used for Cubism-style sprite masking and normal blending. The folder also contains
`SHADER_CONTRACT.md`, which documents the conventions these shaders follow.

## Minimal custom shader example

`shaders/my_grayscale.gdshader`:

```glsl
shader_type canvas_item;

uniform float intensity : hint_range(0.0, 1.0) = 1.0;

void fragment() {
    vec4 color = texture(TEXTURE, UV);
    float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
    COLOR = mix(color, vec4(gray, gray, gray, color.a), intensity);
}
```

## Shader includes

`.gdshaderinclude` files can be shared across shaders:

`shaders/noise_simplex.gdshaderinclude`:
```glsl
float simplex_noise(vec2 p) {
    // ... noise implementation ...
}

float fbm_noise(vec2 p, int octaves, float lacunarity, float gain) {
    float value = 0.0;
    float amplitude = 1.0;
    float frequency = 1.0;
    for (int i = 0; i < octaves; i++) {
        value += amplitude * simplex_noise(p * frequency);
        frequency *= lacunarity;
        amplitude *= gain;
    }
    return value;
}
```

Used in another shader:
```glsl
shader_type canvas_item;
#include "shaders/noise_simplex.gdshaderinclude"

void fragment() {
    float n = fbm_noise(UV * 10.0, 4, 2.0, 0.5);
    COLOR = vec4(n, n, n, 1.0);
}
```

## Adding a new shader

1. Create `shaders/{name}.gdshader` in your mod
2. If it depends on shared functions, create the `.gdshaderinclude` file
3. Reference it in the Godot scene or from C# code
4. If replacing an existing shader, use the same filename — the mod's version takes priority

===== DOC docs/mods/skills/index.md: Skills =====
Skills are card-based abilities that characters can acquire, equip, and use in battle. Defined as JSON data and rendered as skill cards in the UI.

## JSON structure

```jsonc
{
  // Unique skill ID — used for lookups and save data
  "Id": "slave_master",
  // Locale key for the skill's display name on the card
  "Name": "skills/slave_master/name",
  // Locale key for the skill's tooltip/description text
  "Description": "skills/slave_master/desc",
  // Path to card artwork sprite, resolved from the characters/skills mod group
  "Image": "slave_master",
  // Skill activation type: "Active", "Passive", or "ForCharacter"
  "Type": "Passive",
  // Rarity tier: "Common", "Uncommon", "Rare", "Epic", or "Legendary"
  "Rare": "Legendary",
  // Optional: action card granted to the player when the skill is acquired
  // (active skills hand over a card this way)
  "GrantsAction": "physics/attack",
  // Optional: numeric stat bonuses while the skill is owned
  "Effects": [
    { "Key": "characters/skills/effects/recruit", "Value": 10, "Percent": true }
  ]
}
```

## Field reference

| Field | Type | Description |
|---|---|---|
| `Id` | string | Unique skill ID used for lookups and save data |
| `Name` | string | Locale key for display name |
| `Description` | string | Locale key for tooltip/description text |
| `Image` | string | Path to skill card artwork, resolved from mod group `characters/skills` |
| `Type` | string | `"Active"`, `"Passive"`, or `"ForCharacter"` |
| `Rare` | string | `"Common"`, `"Uncommon"`, `"Rare"`, `"Epic"`, or `"Legendary"` |
| `GrantsAction` | string | Optional action card ID pushed into the action pool when the skill is granted |
| `Effects` | array of objects | Optional numeric bonuses granted while the skill is owned — see below |

## Skill types

### `Active`
Must be intentionally triggered during the player's turn, consuming an action resource and targeting a specific ally or enemy. Active skills appear as cards in the player's hand.

### `Passive`
Remains in effect continuously once acquired. Provides a persistent buff or aura without requiring player input. Passive skills do not appear in the hand — they are always active.

### `ForCharacter`
Applies directly to the owning character as a stat modifier or intrinsic trait. These are character-specific upgrades that modify base stats.

### `Effects` — collection bonuses

Each effect is one numeric bonus: `Key` is the locale key naming the affected
stat (e.g. `characters/skills/effects/recruit`), `Value` is the magnitude
(negative values are penalties), and `Percent` selects the display unit
(`+10%` vs `+10`). Effects sharing a `Key` are summed across every owned
skill by `SkillEffectAggregator` into the skills panel's global "collection
effect" plate — so one key must keep the same unit across all skills. The
reference definition `cthangover_game/characters/skills/slave_master.json`
ships one effect: `+10%` recruitment (`"Value": 10, "Percent": true`).

## Granting action cards

An active skill hands the player an action card instead of acting by itself: set `GrantsAction` to the action's `Id`. When the skill is granted, `SkillService` adds the skill to the owned set and appends the card to the action pool, where it can be assigned to a character in the character panel. The skills panel shows the pairing — opening the skill displays "grants an action card" with the card's icon and name.

Granting is idempotent: an already owned skill is ignored, so replaying a scenario event cannot duplicate the card. Action cards awarded this way are not destroyed by the character panel's reset button — reset restores template assignments and returns awarded cards to the pool.

## Rarity tiers

| Tier | Description |
|---|---|
| `Common` | Standard skill — highest drop rate, baseline power |
| `Uncommon` | Slightly improved stats, lower drop rate |
| `Rare` | Notably stronger, appears infrequently |
| `Epic` | Powerful skill with significant gameplay impact, very low drop chance |
| `Legendary` | Game-changing abilities, extremely rare acquisition |

The rarity tier drives the visual frame colour on the skill card via `SkillCardFrameBehaviour`.

## Skill data at runtime

Skill ownership is tracked in `SkillData`:

| Method | Description |
|---|---|
| `HasSkill(id)` | Check if the player owns a skill |
| `AddSkill(id)` | Add a skill to the player's collection (no card grants — prefer `SkillService`) |

Acquisitions go through `SkillService.GrantSkill(id)`, which pairs the skill with its `GrantsAction` card. From a scenario:

```scenario
action character.add_skill skill_id=slave_master
```

The scenario action rejects unknown skill IDs with a log warning and does nothing when the skill is already owned.

## Skill file format

Skill files are self-describing envelopes routed by `Kind`, with no index file:

```jsonc
{
  // Skills are a separate entity kind, not part of characters
  "Kind": "skill",
  "Items": [
    {
      "Id": "slave_master",
      "Name": "characters/skills/slave_master_name",
      "Description": "characters/skills/slave_master",
      "Image": "characters/skills/slave_master",
      "Type": 1,
      "Rare": 3
    }
  ]
}
```

A mod may place this file anywhere; the entity resolver finds it by kind.

## Locale keys

```properties
skills/slave_master/name = Slave Master
skills/slave_master/desc = Increases recruitment chance against defeated enemies by 25%
```

## UI components

| Class | Description |
|---|---|
| `SkillCardBehaviour` | Individual skill card rendering |
| `SkillCardFrameBehaviour` | Card frame with rarity-based colouring |
| `SkillWidget` | Compact skill display widget |
| `SkillsPanelBehaviour` | Skills management panel in game UI |

===== DOC docs/mods/sounds/index.md: Sounds — Sound Effects =====
One-shot sound effect files (`.ogg` / `.wav`) organized by category, referenced from scenarios via the `sound` command.

## Directory structure

```filestree
sounds/
├── battle/           # combat sounds
│   ├── hit.ogg
│   ├── damage.ogg
│   └── stun.ogg
├── locations/        # ambient location sounds
│   ├── door_open.ogg
│   └── fire_crackle.ogg
├── ui/               # interface feedback
│   ├── click.ogg
│   ├── hover.ogg
│   └── notification.ogg
└── record_scratch.ogg
```

## File format

- `.ogg` (Vorbis) and `.wav` are both accepted — see `audio_extensions` in `config/mod_config.json`
- Short duration (0.1–5 seconds) recommended for one-shot effects
- The sound id is the path relative to the mod's `sounds/` folder, minus extension: `sounds/ui/click.ogg` → `ui/click`, `battle/hit`

## Real example — `cthangover_game/sounds/`

From `mods/cthangover_game/sounds/`:

```filestree
sounds/
├── record_scratch.ogg         # comedic timing effect
├── battle/
│   ├── physics_damage.ogg
│   ├── physics_defence.ogg
│   ├── death.ogg
│   └── stun_apply.ogg
├── locations/
│   ├── door_creak.ogg
│   └── wind_ambient.ogg
└── ui/
    ├── button_click.ogg
    ├── notification_chime.ogg
    └── quest_update.ogg
```

## Real example — `card_battle/sounds/`

From `mods/card_battle/sounds/`:

```filestree
sounds/
├── card_pick.ogg              # picking up a card
├── card_drop.ogg              # placing a card on target
├── card_damage.ogg            # damage number appears
├── card_stun.ogg              # stun effect applied
├── card_death.ogg             # card death dissolve
├── turn_start.ogg             # new turn
└── turn_end.ogg               # end turn button
```

## Usage in scenarios

```scenario
sound ui/click
text "- ..." key=dialogue_line
sound battle/hit
delay 0.5
sound ui/notification_chime
```

- `sound` plays immediately and does not block script execution
- Multiple sounds can play simultaneously
- The engine manages audio channels and prevents clipping

## Usage from C#

Sounds are played through `Cthangover.Core.Audio.IAudioService` (implemented by `AudioService`):

```csharp
var audio = GetNodeOrNull<AudioService>("/root/AudioService");

// Exact id
audio.PlaySound("ui/click", SoundType.UI);

// With random variations: picks "battle/hit_1" .. "battle/hit_3"
audio.PlaySound("battle/hit", 3, SoundType.Timed);
```

`SoundType` selects the playback pool — each type gets its own player, so a new
`CardEffect` sound cuts the previous one while a `UI` sound plays independently:
`Timed`, `Background`, `Foreground`, `UI`, `Notification`, `CardEffect`, `CardAction`.

### Engine internals

A few extra members on `AudioService` are useful for mod code:

- `IsMusicPlaying()` — returns `true` when the dedicated Music player is
  currently producing audio; lets UI display playback state without touching
  the player node directly
- `static LinearToDb(float linear)` — public helper converting a linear
  amplitude (0–1) to decibels via `20 * log10(linear)`, clamped to −80 dB at
  the bottom so zero never becomes `-infinity`
- `MixerType` enum (`Sounds`, `Musics`, `Ambient`) — identifies the three audio
  buses (SFX / Music / Ambient) used by `SetVolume(MixerType, float)` and
  `SetEnabled(MixerType, bool)` for per-bus volume and mute control

## Adding a new sound

1. Place the `.ogg` or `.wav` file in `sounds/{category}/`
2. Reference in scenario: `sound category/filename` (no extension)
3. No config or registration needed — audio files are discovered at load time

## Sound categories

Categories are organizational, not enforced. Use whatever folder structure fits your mod:

| Category | Typical use |
|---|---|
| `battle/` | Combat sounds: hits, blocks, stuns, death |
| `locations/` | Environmental: doors, water, wind, fire |
| `ui/` | Interface feedback: clicks, hovers, notifications |
| `music/` | Note: music tracks go in `music/`, not `sounds/` |

For music / ambient tracks, use `music/` and the `music`/`music_play`/`music_pause` scenario commands instead. See `musics/` documentation.

===== DOC docs/mods/src/index.md: C# Source Code in Mods =====
Mods can include C# source files that are compiled at runtime via Roslyn. This allows mods to add new game mechanics, UI, battle systems, and scenario actions without modifying the kernel.

## How it works

1. Kernel discovers `.cs` files via `manifest.json` → `"sources": ["src/**/*.cs"]`
2. Files are loaded as text, compiled via Roslyn into a separate assembly
3. The assembly is loaded into the running game
4. Types implementing known interfaces (`IModInitializer`, `IBattleCore`, `IScenarioAction`, etc.) are auto-discovered

## What gets auto-registered from your assembly

Step 4 happens in `ModAssemblyLoader.RegisterAssembly` (`Core/Mods/ModAssemblyLoader.cs`) — the single point through which every new assembly enters the game. It fans out into every reflection-driven registry, so a public class implementing any of these is discovered without registration code:

| Registry | Discovers |
|---|---|
| `ScenarioActionFactory` | `IScenarioAction` implementations — new `action` commands for the scenario DSL |
| `ScenarioCommandStrategyFactory` | `IScenarioCommandStrategy` implementations, indexed by `Command` name |
| `SceneEventRegistry` (`Core/Scenes/`) | Classes decorated with `[SceneEvent]` deriving from `ExecutableEvent` |
| `ItemActionFactory` | `IItemAction` implementations — item on-use behaviours, indexed by ID |
| `ToolFactory` | `IToolProvider` implementations — tools that add launcher buttons to the main menu's tool block |
| `ToolBoxButtonFactory` | `IToolBoxButton` implementations — toolbox UI buttons |
| `ModInitializerRegistry` | `IModInitializer` mod entry points |
| `ModSaveableRegistry` | `IModSaveable` implementations participating in save/load |
| `ModSettingsRegistry` | `IModSettings` implementations exposed in the settings menu |
| `ModDiscoveryRegistry` | Custom discovery scanners (new places mods can be found) |
| `SectionParserRegistry` | `.tscn` section parsers |
| `PropertyResolverRegistry` | Property resolvers used while building scenes from `.tscn` |
| `SubResourceBuilderRegistry` | Sub-resource builders for `.tscn` construction |
| `ExtResourceBuilderRegistry` | External-resource builders for `.tscn` construction |
| `ModManager.RegisterResolversFromAssembly` | `IModAssetResolver` implementations |

Adding a new reflection-based plugin system to the kernel requires exactly one line in this method. Note that `ScriptTypeRegistry` (below) is populated separately — by `ModRegistry.FinalizeGodotInit`, which calls `ScriptTypeRegistry.RegisterAssembly` right after registering each compiled assembly through `ModAssemblyLoader`.

Subsystems that now live in mods register their own implementations through an `IModInitializer` instead of this fan-out: battle cores and winground listeners (`turn_battle_base`) and recruit behaviours/conditions (`recruiting`). `ModEventBus` typed events with `[EventKey]` can also be declared in any mod — the GDScript bridge reads the attribute at publish time.

## ScriptTypeRegistry — resolving `.tscn` script paths

When a scene file references a script (`path="..."` in its headers), `ScriptTypeRegistry` (`Core/Mods/ScriptTypeRegistry.cs`) maps that path to the compiled `System.Type` inside the mod assembly, respecting mod load-order priority. It is populated by `ModCompiler` after each mod's C# compiles; `ModRegistry.Initialize` supplies the priority order via `SetModOrder`.

Only non-abstract classes deriving from `Godot.Node` are indexed — by class name, full name, and source file path. Paths are normalized before lookup (`res://` prefix stripped, backslashes → forward slashes) and comparisons are case-insensitive.

API (all static):

| Method | Purpose |
|---|---|
| `SetModOrder(List<(string ModId, int Priority)>)` | Sets the mod priority order used during resolution |
| `RegisterAssembly(assembly, modId, sourceFiles)` | Indexes the assembly's node classes and maps source file paths to matching class names |
| `ResolveByPath(tscnPath)` | Priority-ordered lookup by normalized path — first match wins |
| `ResolveByName(className)` | Priority-ordered lookup by class name / full name, with a case-insensitive fallback scan |
| `Resolve(tscnPath)` | Input without `/`, `\` or `.` → name lookup; otherwise path first, then file-name → class-name fallback |
| `Clear()` | Wipes all mappings (called on mod reload) |

## Mod entry point — `IModInitializer`

Mods that need lifecycle logic implement this:

```csharp
public interface IModInitializer
{
    void OnModLoaded(string modId);
    void OnModResourcesReady();
}
```

Implementations are discovered optionally via reflection when the compiled assembly is registered (`ModInitializerRegistry.RegisterAssembly`). Mods that only ship data (JSON scenarios, items, wrappers, etc.) work without any initializer.

## Real example — Card Battle Core

From `mods/card_battle/src/CardBattleCore.cs` (excerpt):

```csharp
namespace Cthangover.CardBattle
{
    public class CardBattleCore : IBattleCore
    {
        public string Id => "card_battle";

        public IActionExecutorProvider ActionProvider { get; }
            = new Actions.CardBattleActionProvider();

        public void Init(Character[] playerChars, Character[] enemyChars,
                         IBattleContext ctx)
        {
            _playerChars = playerChars;
            _ctx = ctx;

            // Deep-copy enemies so defeats are per-instance
            _enemyChars = new Character[enemyChars.Length];
            for (int i = 0; i < enemyChars.Length; i++)
                _enemyChars[i] = enemyChars[i]?.Copy() ?? enemyChars[i];

            ActionExecutorHub.Instance.SetActiveProvider(ActionProvider);

            var root = ctx.RootNode as Node;
            var panel = root.GetNodeOrNull<Control>("Panel") ?? root;

            _playerPanel = (BattleCardPanel)TscnScenes.LoadAndBuild("scenes/battle_card_panel.tscn");
            _playerPanel.Name = "PlayerPanel";
            _playerPanel.EnsureConstructed();
            panel.AddChild(_playerPanel);

            _enemyPanel = (BattleCardPanel)TscnScenes.LoadAndBuild("scenes/battle_card_panel.tscn");
            _enemyPanel.Name = "EnemyPanel";
            _enemyPanel.AlignType = "Right";
            _enemyPanel.EnsureConstructed();
            _enemyPanel.MouseFilter = Control.MouseFilterEnum.Ignore;
            panel.AddChild(_enemyPanel);

            // ... action panel omitted from this excerpt ...

            _endTurnButton = (EndTurnButton)TscnScenes.LoadAndBuild("scenes/end_turn_button.tscn");
            _endTurnButton.Name = "EndTurnButton";
            _endTurnButton.EnsureConstructed();
            _endTurnButton.OnPressed += OnEndTurnPressed;

            _cardController = new CardController { Name = "CardController" };
            panel.AddChild(_cardController);
        }

        public void Start()
        {
            // Position panels, calculate card scale, spawn character cards
            // ...
            StartPlayerTurn();
        }
    }
}
```

Key patterns visible here:
- **Deep copy** enemies to avoid shared state across battles
- **Godot node construction** via `TscnScenes.LoadAndBuild` + `EnsureConstructed()` before `AddChild`, or plain `new` + `AddChild` — the Roslyn assembly is separate from Godot's source generators, so `AddChild` must happen before setting `GlobalPosition`
- **Event subscriptions** via C# events (`OnPressed +=`, `OnCardDead +=`)
- **Async battle flow** via `async void` and `await ToSignal`

## Minimal mod from scratch

`mods/my_mod/src/MyMod.cs`:

```csharp
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;

namespace Cthangover.MyMod
{
    public class MyMod : IModInitializer
    {
        public void OnModLoaded(string modId) { }

        public void OnModResourcesReady()
        {
            GameLogger.Log("MY_MOD", "Мой мод загружен!", LogLevel.Message);
        }
    }
}
```

`mods/my_mod/manifest.json`:

```jsonc
{
    "name": "My First Mod",                                // Human-readable mod name
    "author": "you",                                       // Author credit
    "description": "Learning how to write C# mods",        // Short description
    "sources": ["src/MyMod.cs"]                            // Glob patterns for C# source files to compile via Roslyn
}
```

## Key APIs available to mods

| Namespace / Class | Purpose |
|---|---|
| `Cthangover.Core.Mods.IModInitializer` | Mod entry point: `OnModLoaded(string)` + `OnModResourcesReady()` |
| `Cthangover.Core.Utils.GameLogger` | Structured logging: `GameLogger.Log(module, msg, level)` |
| `Cthangover.Core.Battle.IBattleCore` | Implement to create a battle system |
| `Cthangover.Core.Actions.IScenarioAction` | Implement to add `action` commands to the DSL: `string Name { get; }` + `void Run(IActionContext context)` |
| `manifest.json` → `ui[]` | Declare panels and overlays in the shell — no interface to implement: the kernel instantiates your `.tscn`, anchors it to a named slot, and (for panels) wires a toolbar toggle button. See UI Wrappers |
| `Cthangover.Core.Mods.IModSettings` | Implement to expose settings in the in-game menu |
| `Cthangover.Core.Characters.Character` | Character data model — use `Copy()` for battles |
| `Cthangover.Core.Characters.CharacterAttributes` | Stats container: `Health`, `Point`, `Defence`, `Attack` |
| `Godot.Control` / `Godot.Node2D` | Standard Godot API — all available in mods |

## Logging

Always use `GameLogger` instead of `GD.Print`:

```csharp
GameLogger.Log("MY_MOD", "This is debug info", LogLevel.Debug);
GameLogger.Log("MY_MOD", "Something happened", LogLevel.Message);
GameLogger.Log("MY_MOD", "This might be a problem", LogLevel.Warning);
```

## Important: Godot input in mods

Because mod code compiles into a separate assembly (not processed by Godot's source generators), two rules apply:

1. **`AddChild` BEFORE `GlobalPosition`**: If you set `GlobalPosition` before `AddChild`, the node has no parent canvas transform and the position will be misinterpreted.

```csharp
// CORRECT
parent.AddChild(handle);
handle.GlobalPosition = target;

// WRONG
handle.GlobalPosition = target;
parent.AddChild(handle);
```

2. **Use `GuiInput` event instead of `_GuiInput` override**: Due to Godot dispatch quirks between assemblies, subscribe to the C# event rather than overriding the virtual method:

```csharp
public class MyHandle : ColorRect
{
    public MyHandle()
    {
        MouseFilter = Control.MouseFilterEnum.Stop;
        GuiInput += OnGuiInput;
    }

    private void OnGuiInput(InputEvent @event)
    {
        if (@event is InputEventMouseButton mb && mb.Pressed)
        {
            // handle click
            AcceptEvent();
        }
    }
}
```

## Compilation errors

Roslyn compilation errors appear in the game log. Common issues:
- Missing `using` directives — check namespace imports
- Reference to non-existent APIs — verify the kernel version supports the method
- Syntax errors — standard C# issues

The game will still run even if a mod's C# fails to compile. Non-C# assets (scenarios, items, etc.) from the same mod remain functional.

===== DOC docs/mods/src/gdscript/actions.md: Scenario Actions =====
A GDScript that has both `get_name()` and `run(ctx)` is registered as a **scenario action**. The `ctx` parameter is a `GDActionContext` providing access to all game subsystems.

## Minimal action

`scripts/greet.gd`:
```gdscript
extends RefCounted

func get_name():
    return "my.hello"

func run(ctx):
    ctx.Log("EVENT", "Hello from GDScript!")
```

Usage in `.scenario`:
```scenario
action my.hello
```

## Action with parameters

```gdscript
extends RefCounted

func get_name():
    return "my.announce"

func run(ctx):
    var name = ctx.GetParam("name")
    var count = ctx.GetParam("count")
    if name == "":
        name = "Unknown"
    ctx.Log("EVENT", "Announcing: " + name + " x" + count)
```

```scenario
action my.announce name=Marao count=3
```

## Action context (GDActionContext) API

The `ctx` object provides these properties and methods:

| Member | Type | Description |
|---|---|---|
| `GetParam(name)` | `string` | Read a parameter passed via `key=value` in the DSL |
| `Log(category, msg)` | `void` | Debug-level log |
| `LogWarning(category, msg)` | `void` | Warning-level log |
| `Quests` | `GDQuestService` | Full quest subsystem API |
| `Character` | `GDCharacterService` | Character party management |
| `Battle` | `GDBattleService` | Battle initiation |
| `Lighting` | `GDLightingService` | Lighting/depth map control |
| `Scene` | `GDSceneNodeService` | Scene node instantiation/removal |
| `Inventory` | `GDInventoryService` | Player inventory queries and mutations |
| `ModRegistry` | `GDModRegistryService` | Loaded mod introspection |
| `PublishEvent(key, data)` | `void` | Publish an event to the global event bus (see Events) |

## Service API reference

### GDQuestService

| Method | Description |
|---|---|
| `Exists(id)` | Check if a quest is registered |
| `SetStatus(id, status)` | Set quest lifecycle state (e.g. `"Active"`, `"Completed"`) |
| `SetDataStatus(id, level)` | Set numeric progress counter |
| `AddTag(id, tag)` | Attach a tag for conditional logic |
| `RemoveTag(id, tag)` | Remove a tag |
| `SendNotification(id)` | Trigger "Quest Updated" UI popup |

```gdscript
func run(ctx):
    var qid = ctx.GetParam("quest_id")
    if ctx.Quests.Exists(qid):
        ctx.Quests.SetStatus(qid, "Active")
        ctx.Quests.AddTag(qid, "started_by_gd")
        ctx.Quests.SendNotification(qid)
```

### GDCharacterService

| Method | Description |
|---|---|
| `AddToParty(type)` | Add a character to the player's party |
| `SendNotification(type)` | Show "new party member" UI popup |

### GDBattleService

| Method | Description |
|---|---|
| `Init(sceneType, enemies, questId, newTag)` | Construct battle data and prepare encounter |

Pass empty strings `""` for `questId`/`newTag` to omit them.

### GDLightingService

| Method | Description |
|---|---|
| `ClearDepthMap()` | Reset lighting depth/albedo maps |
| `SetUseTime(useTime)` | Enable/disable time-of-day lighting |

### GDSceneNodeService

| Method | Description |
|---|---|
| `Instantiate(scenePath, nodeName)` | Load a PackedScene and add to scene tree |
| `Remove(nodeName)` | Remove and free a named node |

### GDInventoryService

| Method | Description |
|---|---|
| `HasItem(id)` | Check if item exists in inventory |
| `CheckCount(id)` | Get stack count of an item |
| `Add(id, count)` | Add items (default count=1) |
| `Remove(id, count)` | Remove items (returns `true` on success) |

### GDModRegistryService

| Method | Description |
|---|---|
| `IsLoaded(modId)` | Check if a mod is loaded |
| `GetOrderedModIds()` | Get ordered list of loaded mod IDs |

## Full action example

```gdscript
extends RefCounted

func get_name():
    return "my.quest_and_char"

func run(ctx):
    var quest = ctx.GetParam("quest_id")
    var hero = ctx.GetParam("hero")

    ctx.Quests.SetStatus(quest, "Active")
    ctx.Quests.AddTag(quest, "gds_managed")
    ctx.Quests.SendNotification(quest)

    ctx.Character.AddToParty(hero)
    ctx.Character.SendNotification(hero)

    if ctx.ModRegistry.IsLoaded("cthangover_game"):
        ctx.Log("EVENT", "Core is loaded, all good")
```

===== DOC docs/mods/src/gdscript/battle.md: Battle Action Executors =====
A GDScript that has both `get_action_id()` and `execute(action, user, target)` is registered as a **battle action executor**. The `action_id` must match an `ActionCharacter.ID` in the character's JSON data.

Recognition and registration happen in the turn-battle mod: `TurnBattleGDScriptHook` is installed into the core's `GDScriptHookRegistry`, which `ModGDScriptLoader` offers every compiled `.gd` file. A script that returns a dictionary from `execute` is wrapped in `GDBattleActionExecutor` and added to the global `ActionExecutorHub` pool.

## Executor contract

Arguments are plain Godot dictionaries — mod-authored C# classes get no Godot source generation, so custom RefCounted DTOs would be invisible to GDScript.

```gdscript
extends RefCounted

func get_action_id():
    return "my.custom_attack"

func execute(action, user, target):
    var damage = user["attack_value"] * 2 - target["defence_value"]
    if damage < 1:
        damage = 1

    return {
        "target_damage": damage,
        "target_defence": target["defence_value"],
        "result": true,
    }
```

## Parameters

| Parameter | Type | Description |
|---|---|---|
| `action` | `Dictionary` | The ActionCharacter definition (`id`, `name`, `description`, `type`, `properties`) |
| `user` | `Dictionary` | The character performing the action |
| `target` | `Dictionary` | The character receiving the action |

## `action` fields

| Field | Type |
|---|---|
| `id` | `String` |
| `name` | `String` |
| `description` | `String` |
| `type` | `int` |
| `properties` | `Dictionary` (string → string, the raw `PropertyData` bag) |

## Character fields

| Field | Type | Description |
|---|---|---|
| `id` | `String` | Character identifier |
| `name` | `String` | Display name |
| `level` | `int` | Current level |
| `exp` | `int` | Experience points |
| `recruitment_chance` | `int` | Recruitment chance (0-100) |
| `health_value` / `health_max` | `int` | Current / max health |
| `defence_value` / `defence_max` | `int` | Current / max defence |
| `attack_value` / `attack_max` | `int` | Current / max attack |
| `point_value` / `point_max` | `int` | Current / max action points |
| `properties` | `Dictionary` | Raw string properties (`Speed`, `StunImmune`, …) |

## Return value

`execute` must return a `Dictionary`. Missing keys default to zero; `result` defaults to `true`, so a script that only reports deltas counts as a successful action.

| Key | Type | Description |
|---|---|---|
| `source_damage` | `int` | Damage dealt by the source |
| `source_defence` | `int` | Source defence delta |
| `target_damage` | `int` | Damage dealt to the target |
| `target_defence` | `int` | Target defence delta |
| `result` | `bool` | Whether the action succeeded (default `true`) |

A script that returns a non-dictionary value yields a failed result (`Result = false`), so one broken mod cannot abort the action pipeline.

===== DOC docs/mods/src/gdscript/conditions.md: Custom Conditions =====
A GDScript that has both `get_condition_name()` and `evaluate(flags, args)` is registered as a **custom condition leaf parser**. Conditions can be used in the `condition:` metadata field of `.scenario` files.

## Minimal condition

`scripts/weather_check.gd`:
```gdscript
extends RefCounted

func get_condition_name():
    return "gds.is_rain"

func evaluate(flags, args):
    return flags.get("weather", "") == "rain"
```

Usage:
```scenario
scene: town_entry
priority: 5
condition: gds.is_rain
---
text "It's pouring outside."
end
```

## Condition with arguments

Arguments passed in parentheses become integer-keyed entries in the `args` dictionary:

```gdscript
extends RefCounted

func get_condition_name():
    return "gds.flag_equals"

func evaluate(flags, args):
    var expected = args.get("0", "")
    if expected == "":
        return false
    return flags.get("custom_flag", "") == expected
```

```scenario
condition: gds.flag_equals("victory")
```

## Condition parameters

| Parameter | Type | Description |
|---|---|---|
| `flags` | `Dictionary` | Scene flag dictionary (string → string). Populated by `set` DSL commands and action code. |
| `args` | `Dictionary` | Positional arguments from condition call (string index → string value). Empty if no parentheses. |

===== DOC docs/mods/src/gdscript/index.md: GDScript in Mods =====
Mods can include `.gd` (GDScript) files alongside or instead of C# source code. GDScript files are compiled by Godot's built-in GDScript engine and can implement scenario actions, custom conditions, battle action executors, item actions, mod initializers, and winground listeners — all without Roslyn or a C# toolchain.

## How it works

1. Mod manifest declares `"gd_sources": ["scripts/*.gd"]`
2. On mod load, the kernel finds `.gd` files, compiles them via `GDScript.Reload()`, instantiates, and inspects methods
3. If the GDScript has known method patterns, it is auto-registered into the corresponding subsystem
4. No manual registration code needed — the kernel does it automatically

## manifest.json — gd_sources field

```jsonc
{
  "id": "my_mod",                                          // Canonical mod identifier
  "name": "My GDScript Mod",                               // Human-readable mod name
  "version": "1.0.0",                                      // Semver version
  "gd_sources": ["scripts/*.gd"],                          // Glob patterns for .gd files
  "depends": ["cthangover_game"]                                      // Versioned dependency strings
}
```

| Field | Description |
|---|---|
| `gd_sources` | Glob patterns for `.gd` files |

> **Note on pattern matching:** despite the glob-like syntax, the directory walk is **always recursive**. A pattern like `scripts/*.gd` is split at the last `/`: the part before it (`scripts`) selects the root directory, and the suffix after it (`*.gd`) only acts as a **file name filter**. As a result, `scripts/*.gd` also picks up files such as `scripts/any/deep/foo.gd`. There is no way to limit matching to a single directory level; use distinct top-level folders if you need separation.

A single `.gd` file can implement multiple interfaces simultaneously — the kernel checks all known patterns on each script.

## Pages

- Scenario Actions — actions + GDActionContext + service APIs
- Custom Conditions — condition leaf parsers
- Battle Action Executors — custom battle logic
- Item Actions — item usage handlers
- Mod Lifecycle — initializers, event bus, publishing events

## Auto-detection summary

| GDScript methods | Registered as | Subsystem |
|---|---|---|
| `get_name()` + `run(ctx)` | `IScenarioAction` | Scenario action factory (`run` receives a GDScript action context) |
| `get_condition_name()` + `evaluate(flags, args)` | `ILeafParser` | Condition parser registry (priority over QuestParser) |
| `get_action_id()` + `execute(action, user, target)` | `IActionExecutor` | Battle action executor hub (global); returns a result object converted to attribute changes |
| `get_item_action_id()` + `use(item)` | `IItemAction` | Item action factory; `item` is a GDScript item wrapper, return `bool` |
| `on_mod_loaded(mod_id)` / `on_mod_resources_ready()` | `IModInitializer` | Mod initializer registry; `on_mod_loaded` receives the mod ID string |
| `get_winground_listener_id()` + optional `on_winground_before_exp(ctx)` / `on_winground_after_loot(ctx)` / `on_winground_before_show(ctx)` | `IWingroundListener` | Winground listener registry; all three hooks are optional and receive a context object synced back after the call |
| `on_mod_event_*` | String event subscriber | Global event bus |

Detection details:

- Scenario actions are detected by the presence of **`get_name()`** together with `run()` — there is no separate `get_action_name()` convention.
- Initializers register when *either* callback is present; each missing one is simply not invoked.
- Winground listeners register only if `get_winground_listener_id()` returns a non-empty ID in addition to at least one hook method.

A single `.gd` file can implement multiple patterns simultaneously.

## Order among C# and GDScript

1. C# assemblies are compiled and loaded first (via Roslyn or precompiled DLLs)
2. GDScript files are loaded second
3. All registrations happen during the same load phase

GDScript **conditions** use `RegisterFirst` — they are inserted at the front of the leaf parser list, taking priority over the built-in `QuestParser`. This means `gds.is_rain` resolves before the parser tries to interpret `gds` as a quest ID.

===== DOC docs/mods/src/gdscript/items.md: Item Actions =====
A GDScript that has both `get_item_action_id()` and `use(item)` is registered as an **item action**. The ID must match an `ItemAction` field in the item's JSON definition.

```gdscript
extends RefCounted

func get_item_action_id():
    return "my.heal_potion"

func use(item):
    print("[ITEM] Using: " + item.Name + " (id=" + item.Id + ")")
    # Perform healing logic here
    return true
```

## GDItem fields

| Field | Type | Description |
|---|---|---|
| `Id` | `string` | Item identifier |
| `Name` | `string` | Display name |
| `Description` | `string` | Tooltip text |
| `Cost` | `int` | Purchase price |
| `ItemType` | `int` | Item category flags |

===== DOC docs/mods/src/gdscript/lifecycle.md: Mod Lifecycle =====
A GDScript with `on_mod_loaded(modId)` or `on_mod_resources_ready()` is registered as a **mod initializer**. The kernel calls these lifecycle hooks:

```gdscript
extends RefCounted

func on_mod_loaded(mod_id):
    print("[MY_MOD] on_mod_loaded: " + mod_id)

func on_mod_resources_ready():
    print("[MY_MOD] All mod resources are ready")
```

| Hook | Called when |
|---|---|
| `on_mod_loaded(modId)` | Another mod finishes loading. Called for every mod (including itself). |
| `on_mod_resources_ready()` | All mods and their JSON resources are fully loaded. Called once. |

## Event bus auto-registration (`on_mod_event_*`)

GDScript objects are automatically scanned for methods starting with `on_mod_event_`.
Each method is subscribed to the string event bus under the key after the prefix:

```gdscript
# scripts/quest_watcher.gd
func on_mod_event_quest_status_changed(data: Dictionary) -> void:
    if data.quest_id == "main_quest" and data.new_status == "completed":
        print("Quest completed!")

func on_mod_event_battle_ended(data: Dictionary) -> void:
    if data.result == "victory":
        heal_party()
```

The method receives a `Dictionary` with `snake_case` keys matching the event's
typed C# properties. See Events for the full list of built-in events
and their payloads.

## Publishing events from scenario actions

Scenario action scripts can publish events via `ctx.PublishEvent()`:

```gdscript
func get_name() -> String:
    return "cooking.meal_cooked"

func run(ctx) -> void:
    ctx.PublishEvent("cooking_meal_ready", {
        "dish": ctx.GetParam("dish"),
        "quality": ctx.GetParam("quality")
    })
```

Other mods (C# or GDScript) can subscribe to this event by its string key.
See Events for the full event bus API.

===== DOC docs/mods/status_effects/index.md: Status Effects =====
Status effects are temporary buffs, debuffs, or crowd-control effects applied to characters during battle. Defined as JSON data and managed through `StatusEffectQueue`; the runtime is owned by the turn-battle mod (`turn_battle_base`) and keyed per character via `StatusEffectService`, so the core character model stays free of battle state.

## JSON structure

```jsonc
{
  // Unique status effect identifier — used to look up and apply effects
  "Id": "stun",
  // Locale key for the effect's display name in tooltips and the status panel
  "Name": "status/stun/name",
  // Locale key explaining what the effect does
  "Description": "status/stun/desc",
  // Effect category: "Buff" (positive), "Debuff" (negative), or "Stun" (skip turn)
  "Type": "Stun",
  // Number of turns the effect lasts before expiring
  "Duration": 2,
  // Behaviour set ID — resolved via StatusEffectActionFactory into IStatusActions
  "Actions": "stun",
  // Icon filename loaded from the characters mod group
  "Icon": "stun"
}
```

## Field reference

| Field | Type | Description |
|---|---|---|
| `Id` | string | Unique effect identifier |
| `Name` | string | Locale key for display name (shown in tooltips and status panel) |
| `Description` | string | Locale key explaining what the effect does |
| `Type` | string | `"Buff"`, `"Debuff"`, or `"Stun"` |
| `Duration` | int | Number of turns the effect lasts before expiring |
| `Actions` | string | Behaviour set ID, resolved via `StatusEffectActionFactory` into an `IStatusActions` instance |
| `Icon` | string | Icon filename loaded from the `characters` mod group |

## Status effect types

| Type | Description |
|---|---|
| `Buff` | Positive effect that benefits the bearer (e.g. stat boost, regeneration). Rendered with a friendly tint in the UI |
| `Debuff` | Negative effect that harms the bearer (e.g. damage over time, stat reduction). Rendered with a hostile tint |
| `Stun` | Crowd-control — causes the bearer to skip their turn. Checked by the battle engine via `StatusEffectQueue.HasStun()` |

## Status effect file format

Status effect files are self-describing envelopes routed by `Kind`, with no
index file:

```jsonc
{
  "Kind": "status_effect",
  "Items": [
    {
      "Id": "effect/physics/stun",
      "Name": "status/",
      "Description": "",
      "Icon": "characters/effects/stun",
      "Type": "Stun",
      "Duration": 3,
      "Actions": "basic/stun"
    }
  ]
}
```

## Runtime behaviour

- Each battle combatant gets a `StatusEffectQueue` from `StatusEffectService.Of(character)` — an ordered list of active status effects
- `RemainingTurns` decrements in both turn phases: in `OnTurnStart` (only for timed effects with `Duration > 0`) and unconditionally in `OnTurnEnd`
- When `RemainingTurns` reaches 0, natural expiry fires `OnFinalAction`; manual removal via `Remove()` fires `OnRemove` instead
- The battle engine checks `HasStun()` to skip a stunned character's turn
- Effect behaviour callbacks are provided by `IStatusActions`, resolved from the `Actions` field via the factory

## StatusEffectQueue API reference

The queue lives in `mods/turn_battle_base/src/StatusEffect/StatusEffectQueue.cs` (namespace `Cthangover.Core.Cards.StatusEffect`). It is not a field of `Character` any more: `StatusEffectService.Of(character)` returns (and lazily creates) the queue from a `ConditionalWeakTable`, so queues die with their characters and the core model never references battle types. Battle code must go through the service instead of a `character.StatusEffectQueue` property.

| Member | Description |
|---|---|
| `ActiveStatusEffects` | Defensive copy of the internal effect list (a new `List` per access) — safe to enumerate, external code cannot mutate the backing store |
| `Add(string statusEffectId, int duration = -1)` | Resolves the ID via `StatusEffectInfoFactory`, constructs a `StatusEffectItem`, fires `OnApply`, and appends it. Returns `false` when the factory lookup fails. A positive `duration` overrides both `Duration` and `RemainingTurns` from the definition |
| `Remove(string statusEffectId)` | Removes the first effect matching the ID, firing `OnRemove` first. Returns `false` if nothing matched |
| `HasStun()` | `true` if any active effect has type `Stun` — checked by the battle engine to skip turns |
| `OnTurnStart()` | Fires `OnTurnStart` on each effect, decrements `RemainingTurns` for timed effects (`Duration > 0`), and expires at zero via `OnFinalAction` |
| `OnTurnEnd()` | Fires `OnTurnEnd` on each effect, decrements `RemainingTurns` unconditionally, removes expired effects via `Remove` (which also fires `OnRemove`) |
| `OnDealDamage(Character target, ref int damage)` | Pipes outgoing damage through every effect's `OnDealDamage` hook, in insertion order |
| `OnTakeDamage(Character source, ref int damage)` | Pipes incoming damage through every effect's `OnTakeDamage` hook |
| `ClearAll()` | Fires `OnRemove` on each active effect, then clears the list |
| `Copy(Character newOwner)` | Builds a new queue bound to `newOwner`; each effect is cloned via `Copy()` and `OnApply` is re-fired in the new owner's context |

Timing semantics worth knowing:

- `IStatusEffect.Duration <= 0` means the effect never expires naturally — only `Remove()` or `ClearAll()` ends it
- `StatusEffectItem.RemainingTurns` is tracked independently of the definition's `Duration`, so the original value survives for display or re-apply logic
- `StatusEffectItem.Copy()` is a shallow clone: primitives are copied while the stateless `Actions` singleton is shared by reference

Built-in behaviour sets live under `mods/turn_battle_base/src/StatusEffect/Impls/` — e.g. `StunStatusEffect` (`ID = "basic/stun"`) implements every `IStatusActions` hook as a no-op, because stun is detected via `HasStun()` rather than through action callbacks.

## Implementing custom status effect behaviour

Create an `IStatusActions` implementation. Registration is fully automatic: the `StatusEffectActionFactory` constructor calls `Reflections.FindAndCreate<IStatusActions>()`, which scans every assembly loaded in the AppDomain (mod assemblies included) — no manual registration is needed:

```csharp
public class PoisonStatusActions : IStatusActions
{
    // Mandatory factory key matching the JSON "Actions" field (e.g. "basic/stun")
    public string ID => "basic/poison";

    public void OnApply(Character target) { /* called when effect is applied */ }
    public void OnTurnStart(Character target) { /* start-of-turn tick: DoT, regen */ }
    public void OnTurnEnd(Character target) { /* end-of-turn hook */ }
    public void OnDealDamage(Character source, Character target, ref int damage) { /* amplify/reduce outgoing damage */ }
    public void OnTakeDamage(Character target, Character source, ref int damage) { /* amplify/reduce incoming damage */ }
    public void OnRemove(Character target) { /* manual removal (cleanse) */ }
    public void OnFinalAction(Character target) { /* natural expiry */ }
    public void ModifyAttributes(CharacterAttributes attributes) { /* passive stat modifier */ }
}
```

The mandatory `string ID` property is the factory key matched against the effect's `Actions` field.

## Locale keys

```properties
status/stun/name = Stunned
status/stun/desc = Character skips their next turn
```

===== DOC docs/mods/time/index.md: Time System =====
The in-game clock tracks time progression and drives day/night lighting cycles. Time affects quest progression, character stats, and scene appearance via the `light_use_time` feature.

## Time data structure

Time is tracked in `TimeData` as a single tick counter, from which all other values are derived:

| Property | Type | Description |
|---|---|---|
| `Tick` | `long` field | Raw clock tick (increments by 1 per in-game minute) |
| `Years` | `int` | Current year |
| `Months` | `int` | Current month (0–11) |
| `Days` | `int` | Current day (0–29) |
| `Hours` | `int` | Current hour (0–23) |
| `Minutes` | `int` | Current minute (0–59) |
| `Text` | `string` | Formatted display string (`"HH:MM"`) |

### Time-of-day properties

| Property | Description |
|---|---|
| `IsMorning` | True between 06:00 and 09:59 |
| `IsDay` | True between 10:00 and 17:59 |
| `IsEvening` | True between 18:00 and 21:59 |
| `IsNight` | True between 22:00 and 05:59 |
| `Phase` | `PhaseType` enum: `Morning`, `Day`, `Evening`, `Night` |
| `Normalized` | Float hour of day — `Hours + Minutes / 60f`, range 0.0–23.983 — used by lighting shaders |

## Time phases

| Phase | Hour Range | Description |
|---|---|---|
| `Morning` | 06:00–09:59 | Dawn lighting |
| `Day` | 10:00–17:59 | Full daylight |
| `Evening` | 18:00–21:59 | Dusk lighting |
| `Night` | 22:00–05:59 | Dark, lamp-dependent |

## Time advancement

- `TimeTickController` advances time on a real-time timer when enabled
- Scenario actions can manually set or advance time
- `TimeData.AddTick()` increments by one minute, recalculating all derived properties
- `TimeData.AddTime(years, months, days, hours, minutes)` advances by a specific amount

## Lighting integration

The `light_use_time` DSL command and `light_use_time` metadata field control time-of-day-driven lighting:

```scenario
light_use_time true
```

When enabled, the lighting shader reads `TimeData.Normalized` to adjust scene colour temperature and lamp intensity based on the time of day. The player's lamp (`LampData`) provides a radius and influence value that the shader uses as a light source during night phases.

In scenario metadata:

```yaml
scene: home_kitchen
priority: 10
light_use_time: true
---
# scenario body
```

## Saving

Time is persisted in save files as a `long` tick value. On load, all derived properties (hours, phases, etc.) are recalculated.

===== DOC docs/mods/wrappers/index.md: Wrappers — UI Templates =====
A mod can attach its own UI (panels, overlays, widgets) to the game shell in two ways:

- **Manifest `ui[]` entries** — the supported, declarative way. A mod declares panels and overlays in `manifest.json`; the kernel instantiates the corresponding `.tscn` scenes, anchors them to named slots, and (for panels) wires a toolbar toggle button.
- **Wrapper template files (`.wrappertmpl`)** — plain-text UI tree templates collected from mods. The collection pipeline still exists, but **nothing instantiates these templates automatically**; see Wrapper template files.

The old `manifest.json → subscriptions` array (template + code fragment + trigger) is **no longer part of the manifest schema** and has been removed from the engine.

## Attaching mod UI — the `ui` manifest entries

Each entry of the manifest's `ui` array declares one GUI element. Two kinds exist:

- `"kind": "panel"` — button-toggled panel. The UI registry owns a toolbar button for it (declared in `button`) and is the only one allowed to change its visibility. Panels always live for the whole session.
- `"kind": "overlay"` — no button; the element's own code decides when it is visible (optionally auto-shown with `show_on_start`).

Real example — `mods/cooking/manifest.json`:

```jsonc
{
  "ui": [
    {
      "id": "cooking_workbench",               // Unique within the mod; doubles as the toggle target
      "kind": "overlay",                       // No toolbar button
      "scene": "home_kitchen",                 // Only available in this logical scene
      "slot": "ModLastPanel",                  // Anchor slot node in the shell
      "scene_path": "scenes/cooking_panel.tscn", // Mod-relative .tscn, loaded at runtime
      "once": true,                            // Lives for the whole application
      "show_on_start": false,                  // Waits for a toggle or its own code
      "priority": 10                           // Layer/order inside the slot
    },
    {
      "id": "ration_stats",
      "kind": "overlay",
      "scene": "home_kitchen",
      "slot": "Lastground",
      "scene_path": "scenes/ration_statistics_panel.tscn",
      "once": false,                           // Created on scene enter, freed on scene exit
      "show_on_start": true,                   // Shown as soon as the scene is entered
      "priority": 20
    }
  ]
}
```

A `panel` entry additionally declares its toolbar button — from `mods/cthangover_game/manifest.json`:

```jsonc
{
  "id": "map",
  "kind": "panel",
  "slot": "ModBeforeUIPanel",
  "scene_path": "scenes/ui/map_widget.tscn",
  "button": {
    "icon": "map",              // Icon name resolved through the UI icon factory
    "locale": "ui/map/title",   // Tooltip/title key from locale files
    "index": 10                 // Button position on the toolbar
  },
  "priority": 10
}
```

### Entry fields

| Field | Applies to | Description |
|---|---|---|
| `id` | both | Unique within the owning mod; doubles as the runtime cache key and the toggle target (e.g. for `ui.toggle_panel`) |
| `kind` | both | `"panel"` (button-toggled) or `"overlay"` (code-controlled visibility) |
| `scene` | both, optional | Logical scene this GUI belongs to; the GUI is hidden when leaving it. Absent = scene-independent, lives for the whole session |
| `slot` | both | Name of the anchor slot node in the shell (e.g. `ModBeforeUIPanel`, `ModLastPanel`, `Lastground`) |
| `scene_path` | both | Mod-relative path to a `.tscn` scene, resolved through the runtime TSCN loader — see Runtime TSCN Scenes |
| `once` | overlay | `true`: the instance lives for the whole application; `false` (default): created on scene entry, freed on scene exit. Panels are always treated as `true` |
| `show_on_start` | overlay | `true`: shown as soon as its scene is entered; `false` (default): waits for a registry toggle or the element's own code. Ignored for panels |
| `priority` | both | Layer/order inside the anchor slot. Defaults to `10`; ordering ties between mods are broken by mod load order |
| `button` | panel only | Toolbar button declaration: `icon`, `locale`, `index`. Forbidden for overlays |

Entries are validated at startup by `UiRegistry.Initialize`; invalid entries are logged and skipped. Panels get their toolbar buttons from the toolbox (`ToolBox.ApplyPanelButtons`) and are toggled with `UiRegistry` toggles — the same mechanism the DSL action `ui.toggle_panel` drives (see Scenario Actions). The in-game map is itself a `panel` UI entry — see Map & Travel Zones.

## Scene lifecycle hooks

To run logic when a scene is entered or exited, use the event mechanisms instead of the removed `subscriptions` array:

- `.scenario` scene events — see Scenes and Scenarios;
- the `[SceneEvent]` attribute for declarative C# hooks — see Scene Event Metadata.

Both are composed into one synchronous chain by `SceneManager.ComposeEvents` on scene enter; subscribers run in priority order (0/5/10/30). For the event bus used inside those hooks, see Event Bus.

## Wrapper template files (`.wrappertmpl`)

The engine still collects wrapper template files from mods:

```filestree
wrappers/
├── default.wrappertmpl
└── custom_layout.wrappertmpl
```

- `WrapperTemplateResolver` scans every loaded mod recursively for `.wrappertmpl` files and caches the result.
- The static `WrapperTemplates.Collect()` facade returns them as `WrapperDefinition` records (`ModId`, `Name` = filename minus extension, `Content` = raw text).

**Currently nothing consumes them.** There is no automatic instantiation on scene enter, no `{{USER_CODE}}` substitution, and no manifest field that would bind a template to a scene — that pipeline was removed together with the manifest `subscriptions` array. A mod (or a future engine feature) can still call `WrapperTemplates.Collect()` and instantiate the raw template text itself; the content is kept as a raw string because it is parsed at instantiation time, not JSON.

## Building mod UI from code — ModWidget

When creating UI components from mod C# code, inherit from `ModWidget` (`Core/UI/Base/Lists/ModWidget.cs`). It provides an idempotent `Construct()`/`Destruct()` lifecycle for UI built from code inside mods: `EnsureConstructed()` calls the abstract `Construct()` exactly once, and the virtual `Destruct()` is called automatically on `_ExitTree`. Usage = override `Construct()` and build your children there:

```csharp
public class MyUIPanel : ModWidget
{
    protected override void Construct()
    {
        var label = new Label { Text = "Hello from mod!" };
        AddChild(label);
    }
}
```

Call `EnsureConstructed()` before adding the widget to the tree (as `CardBattleCore` does), so its children are constructed exactly once regardless of how many times construction is requested.

===== DOC docs/mods/zip/index.md: Mod Packaging — .zip Distribution =====
Mods can be distributed as `.zip` archives instead of loose folders. The kernel loads them identically — zip is just a transport format.

## When to use .zip vs folder

| Format | Use case |
|---|---|
| **Folder** | Local development. Instant iteration — edit files, reload game |
| **.zip** | Distribution. Share with players, upload to mod platforms, versioned releases |

## Directory structure inside the zip

The zip must have the same structure as a loose mod folder:

```filestree
my_mod.zip/
├── manifest.json
├── src/
│   └── MyCode.cs
├── scenarios/
│   └── my_scene.scenario
├── items/
│   └── rations.json
├── backgrounds/
│   └── my_bg.png
├── locale/
│   └── en.properties
└── ...                     # any other assets
```

The root of the zip IS the mod root — no extra wrapper folder.

## How the kernel loads zipped mods

1. On startup, the registered mod scanners (`UserDataModScanner` and `GameDirModScanner`) enumerate filesystem entries in their respective `mods/` directories — both folders and `.zip` files are returned as `DiscoveredModEntry` items
2. The `ZipModLoader` claims any entry path ending with `.zip` and creates a `ZipModFileProvider` — the archive is opened directly with `ZipFile.OpenRead`, building an O(1) entry lookup dictionary in memory without extracting files to disk
3. The mod is loaded as if it were a folder — `manifest.json` is read from inside the zip, sources compiled, assets discovered
4. Zip mods have the same dependency resolution and lifecycle as folder mods; deduplication priority is controlled by the scanner's `ModSourceRoot.Priority`, not by format type

See Resources — Loaders for details on how `ZipModLoader` and custom loaders work.

## Creating a .zip for distribution

From the mod's parent directory:

```bash
# From mods/
zip -r my_mod_v1.0.0.zip my_mod/
```

Or using the Python build script (add this to your mod's tooling):

```python
import shutil
import json

def pack_mod(mod_path, output_path):
    with open(f"{mod_path}/manifest.json") as f:
        manifest = json.load(f)

    name = manifest.get("name", mod_path)
    version = manifest.get("version", "0.0.0")
    archive = f"{output_path}/{name}_v{version}"

    shutil.make_archive(archive, "zip", mod_path)
    print(f"Packed: {archive}.zip")
```

## Testing a zipped mod

1. Place the `.zip` in `mods/`
2. Remove the source folder (or keep both — if both a folder and a zip share the same mod ID, only one loads: a higher version always wins regardless of enumeration order; when versions are equal the first-encountered entry wins within the same root priority, and a lower-priority root always loses)
3. Launch the game — the mod loads from the zip
4. Check the game log for any errors

## Mod versioning

Include a `version` field in `manifest.json` for proper version tracking:

```jsonc
{
  "name": "MyMod",                                         // Human-readable mod name
  "version": "1.2.0",                                      // Semver version for tracking and compatibility
  "author": "you",                                         // Author credit
  "description": "..."                                     // Short description of the mod
}
```

The kernel **does** enforce version deduplication — `ModRegistry.RegisterCandidates()` keys candidates by mod `Id`, so only **one** version of a given mod ever loads:

- Within the same root priority: a candidate with a **higher version replaces** the already-registered one (logged as a warning); an equal or lower version is **skipped with a warning** — for equal versions, first-encountered wins.
- A candidate from a **lower-priority root never replaces** one from a higher-priority source, regardless of version.

Use version numbers for player information and mod platform compatibility checks.

## Zip mod limitations

- **No hot-reload**: Editing files inside a zip requires repacking. Use folders during development
- **No filesystem paths**: `GetFileSystemPath()` always returns `null` for zip mods — Godot's `ResourceLoader` cannot load scenes or shaders directly from within a zip. Assets that require filesystem access (shaders, scenes) are extracted to the mod cache on demand
- **File size**: No hard limit on zip size, but the entry lookup dictionary grows linearly with the number of files — very large archives (>10,000 entries) may increase the initial dictionary build time

## Existing test zips

The project includes test zip mods in `mods/` — these demonstrate the zip loading system:

```filestree
mods/
├── test_mod.zip
├── test_mod_a.zip
└── test_mod_b.zip
```

These are minimal mods used in automated tests to verify zip loading works correctly.

===== DOC docs/resources/index.md: Resources — Pluggable Mod Infrastructure =====
The kernel's resource pipeline is built on a strategy pattern that lets mods extend *how* mods are discovered, *where* they come from, and *how* they are read. This page gives an overview; each subsection covers one layer in detail.

## Architecture

Three layers work together to turn a URL or folder path into a loaded mod:

```
Scanner (IModScanner)          "I know where mods live"
    ↓
Loader  (IModLoader)           "I know how to open this kind of mod"
    ↓
Provider (IModFileProvider)    "I provide file access to a loaded mod"
```

Scanners and loaders are discovered automatically via `Reflections.FindAndCreate<T>()` and registered with `ModDiscoveryRegistry` — any public, non-abstract class implementing the interface with a parameterless constructor is picked up at startup. Mod DLLs can ship their own scanners/loaders; they are registered when the mod assembly is loaded and take priority over built-in ones via LIFO lookup.

Providers are **not** part of this registry — there is no provider registry. Each loader constructs its provider directly: `FolderModLoader` creates a `FolderModFileProvider`, `ZipModLoader` creates a `ZipModFileProvider`. To supply custom storage (memory, network, database), a mod ships its own `IModLoader` whose `CreateProvider()` returns the custom `IModFileProvider`.

## Layers at a Glance

| Layer | Interface | Built-in implementations | What mods can add |
|---|---|---|---|
| **Providers** | `IModFileProvider` | `FolderModFileProvider`, `ZipModFileProvider` | Custom storage backends — shipped as an `IModLoader` that constructs them in `CreateProvider()` (see Loaders) |
| **Scanners** | `IModScanner` | `UserDataModScanner`, `GameDirModScanner` | New mod sources (HTTP catalog, Steam Workshop, Git repos) |
| **Loaders** | `IModLoader` | `FolderModLoader`, `ZipModLoader` | New mod formats (.7z, .tar.gz, remote zip download) |
| **Registry** | `ModDiscoveryRegistry` | Static singleton | — (mods extend via scanners & loaders, not the registry itself) |

## How a mod adds a custom scanner — quick example

A mod that wants to load mods from an HTTP catalog ships one class:

```csharp
using System.Collections.Generic;
using Cthangover.Core.Mods.Discovery;

namespace MyMod
{
    public class HttpCatalogScanner : IModScanner
    {
        public string Name => "HTTP Catalog";

        public bool IsCanProvide(string url)
        {
            return url.StartsWith("https://");
        }

        public IEnumerable<ModSourceRoot> GetRoots()
        {
            yield return new ModSourceRoot
            {
                Url = "https://catalog.example.com/mods",
                Priority = 10
            };
        }

        public IEnumerable<DiscoveredModEntry> Discover(string rootUrl)
        {
            // HTTP GET rootUrl, parse JSON array of mod URLs,
            // yield return new DiscoveredModEntry { ... } for each
        }
    }
}
```

No registration code needed — `Reflections.FindAndCreate<IModScanner>()` finds it automatically when the mod's assembly is loaded.

## Priority and LIFO lookup

- **Root priority** (`ModSourceRoot.Priority`): lower number = higher precedence in deduplication. `UserDataModScanner` uses 0 (user mods win), `GameDirModScanner` uses 1 (built-in mods are overridden).
- **LIFO lookup (scanners/loaders)**: the **last-registered** scanner or loader that says `IsCanProvide(url) == true` wins. Since mod-added implementations are registered after built-in ones, they are checked first and can override core behaviour. This applies only to scanners and loaders — providers are never looked up, they are constructed by their loader.

===== DOC docs/resources/discovery-registry/index.md: Discovery Registry — `ModDiscoveryRegistry` =====
The central registry that auto-discovers all `IModScanner` and `IModLoader` implementations and provides LIFO-ordered lookup. There is exactly one instance — a static singleton initialised once by `ModRegistry.Initialize()`.

## Discovery

At startup, `Reflections.FindAndCreate<T>()` scans all loaded assemblies for public, non-abstract classes implementing `IModScanner` or `IModLoader` with a parameterless constructor. `Initialize()` is idempotent — an `_initialized` guard makes repeated calls no-ops:

```csharp
public static void Initialize()
{
    if (_initialized)
        return;
    _initialized = true;

    _scanners.AddRange(Reflections.FindAndCreate<IModScanner>());
    _loaders.AddRange(Reflections.FindAndCreate<IModLoader>());
}
```

When a mod assembly is loaded, `ModAssemblyLoader.RegisterAssembly()` fans out to `ModDiscoveryRegistry.RegisterAssembly()`, which runs the same discovery on the new assembly — appending any found scanners/loaders to the end of the registration lists.

## LIFO lookup

Both `FindScanner(url)` and `FindLoader(url)` walk the registration list **from the end** (last-registered first). This gives mod-added providers priority over built-in ones:

```csharp
public static IModLoader FindLoader(string url)
{
    for (int i = _loaders.Count - 1; i >= 0; i--)
        if (_loaders[i].IsCanProvide(url))
            return _loaders[i];
    return null;
}
```

## Registration order (typical)

| Step | Registered | Order in list |
|---|---|---|
| Core assembly init | `UserDataModScanner`, `GameDirModScanner` | first |
| Core assembly init | `FolderModLoader`, `ZipModLoader` | first |
| Mod A loaded | `CommunityCatalogScanner` | End → checked first |
| Mod B loaded | `HttpZipLoader` | End → checked first |

Exact index positions of the built-in scanners/loaders are **not guaranteed** — the order within a single assembly comes from the assembly/type enumeration performed by `Reflections.FindAndCreate<T>()`. The only reliable property is relative order: mod-added implementations are always appended after built-in ones, and LIFO lookup walks from the end.

After Mod A and Mod B load:
- `FindLoader("https://example.com/mods/cool.zip")` → `HttpZipLoader` (last, checked first) ✓
- `FindLoader("C:/mods/cool")` → `FolderModLoader` (only built-in matching)
- `FindScanner("https://catalog.example.com/mods")` → `CommunityCatalogScanner` ✓

## Public API

```csharp
public static class ModDiscoveryRegistry
{
    // All registered scanners/loaders in registration order
    public static IReadOnlyList<IModScanner> Scanners { get; }
    public static IReadOnlyList<IModLoader> Loaders { get; }

    // Idempotent init — scans assemblies for implementations
    public static void Initialize();

    // Called by ModAssemblyLoader when a mod DLL loads
    public static void RegisterAssembly(Assembly assembly);

    // LIFO search — returns first matching provider or null
    public static IModScanner FindScanner(string url);
    public static IModLoader FindLoader(string url);
}
```

## How `ModRegistry` uses the registry

The full discovery flow in `ModRegistry.Initialize()`:

```csharp
// 1. Discover all scanners and loaders
ModDiscoveryRegistry.Initialize();

// 2. For each scanner, enumerate its roots
foreach (var scanner in ModDiscoveryRegistry.Scanners)
{
    foreach (var root in scanner.GetRoots())
    {
        if (!scanner.IsCanProvide(root.Url))
            continue;  // Root not accessible — skip

        // 3. For each discovered entry, find a loader
        foreach (var entry in scanner.Discover(root.Url))
        {
            // Entries named ".gdignore" / ".gitkeep" are skipped
            var loader = ModDiscoveryRegistry.FindLoader(entry.Url);
            if (loader == null)
                continue;  // No loader claims this entry

            // 4. Create provider, read manifest, build candidate
            var provider = loader.CreateProvider(entry.Url, entry.EntryName);
            var manifest = ReadManifest(provider);
            // ... register candidate for deduplication ...
        }
    }
}
```

Deduplication happens afterwards in `ModRegistry.RegisterCandidates()`: candidates are keyed by mod `Id`, compared first by root priority (lower wins), then by version (higher version wins). See Load order and the zip packaging page for details.

===== DOC docs/resources/loaders/index.md: Loaders — `IModLoader` =====
A **loader** takes a single mod entry URL (returned by a scanner's `Discover()`) and creates the `IModFileProvider` that gives file access to that mod. It also provides the fallback mod ID used when `manifest.json` has no `id` field.

Loaders are matched to entries via `IsCanProvide(url)` — the first loader that says "I can handle this" (in LIFO order) gets to create the provider.

## Base interface — `IModDiscoveryProvider`

Both `IModLoader` and `IModScanner` extend a common base,
`IModDiscoveryProvider` (`Core/Mods/Discovery/IModDiscoveryProvider.cs`):

```csharp
public interface IModDiscoveryProvider
{
    string Name { get; }                // Human-readable name for log messages ("Folder", "Zip", ...)
    bool IsCanProvide(string url);      // Can this provider handle the given path or URL?
}
```

The shared contract is what lets `ModDiscoveryRegistry` discover both kinds of
providers via `Reflections.FindAndCreate` and apply the same LIFO lookup
semantics: the last-registered provider that answers `true` to
`IsCanProvide` wins. For loaders `IsCanProvide` is a type-dispatch check
(is it a folder? a zip?); for scanners it acts as an accessibility guard
(does the directory exist?).

## Interface

```csharp
public interface IModLoader : IModDiscoveryProvider
{
    string Name { get; }                                          // "Folder", "Zip", "HTTP Download", ...
    bool IsCanProvide(string url);                                // Type-dispatch: is it a folder? a zip?
    IModFileProvider CreateProvider(string url, string modName);  // Create the file provider
    string GetFallbackModId(string url, string entryName);        // ID when manifest has no id field
}
```

## Built-in loaders

### `FolderModLoader`

Claims any path that `Directory.Exists()` returns `true` for. Creates a `FolderModFileProvider` — the only provider type that supports Godot scene instantiation.

| Method | Behaviour |
|---|---|
| `IsCanProvide(url)` | `Directory.Exists(url)` |
| `CreateProvider(url, modName)` | `new FolderModFileProvider(url, modName)` |
| `GetFallbackModId(url, entryName)` | Returns `entryName` unchanged |

### `ZipModLoader`

Claims any path ending with `.zip` (case-insensitive). Creates a `ZipModFileProvider` that opens the archive and builds an O(1) entry lookup.

| Method | Behaviour |
|---|---|
| `IsCanProvide(url)` | `url.EndsWith(".zip", OrdinalIgnoreCase)` |
| `CreateProvider(url, modName)` | `new ZipModFileProvider(url, modName)` |
| `GetFallbackModId(url, entryName)` | `Path.GetFileNameWithoutExtension(entryName)` — strips the `.zip` extension |

## Loader matching order

Loaders are checked **LIFO** (last-registered first). This means:
- Built-in loaders are registered first (Folder, then Zip)
- Mod-added loaders are registered later and checked before built-ins
- A mod can override `ZipModLoader` with its own zip handler by claiming `.zip` paths first

The kernel checks for a matching loader like this:

```csharp
var loader = ModDiscoveryRegistry.FindLoader(entry.Url);  // LIFO search
if (loader == null)
    return;  // skip this entry — no loader claims it

var fileProvider = loader.CreateProvider(entry.Url, entry.EntryName);
var manifest = ReadManifest(fileProvider);
var id = manifest.Id ?? loader.GetFallbackModId(entry.Url, entry.EntryName);
```

## Adding a custom loader via a mod

A loader that downloads and opens a remote zip:

```csharp
using System;
using System.IO;
using System.Net.Http;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Discovery;
using Cthangover.Core.Mods.Providers;

namespace MyMod
{
    public class HttpZipLoader : IModLoader
    {
        public string Name => "HTTP Zip";

        public bool IsCanProvide(string url)
        {
            return url.StartsWith("https://") && url.EndsWith(".zip");
        }

        public IModFileProvider CreateProvider(string url, string modName)
        {
            using var client = new HttpClient();
            var bytes = client.GetByteArrayAsync(url).Result;

            var tempPath = Path.Combine(Path.GetTempPath(), $"{modName}.zip");
            File.WriteAllBytes(tempPath, bytes);

            return new ZipModFileProvider(tempPath, modName);
        }

        public string GetFallbackModId(string url, string entryName)
        {
            return Path.GetFileNameWithoutExtension(entryName);
        }
    }
}
```

Paired with the `CommunityCatalogScanner` from the Scanners page, you now have a complete HTTP mod pipeline: scanner discovers URLs → loader downloads and opens zips → `ZipModFileProvider` provides file access.

## Custom archive format loader

A loader for `.7z` archives could use the same pattern:

```csharp
public class SevenZipLoader : IModLoader
{
    public string Name => "7-Zip";

    public bool IsCanProvide(string url)
    {
        return url.EndsWith(".7z", StringComparison.OrdinalIgnoreCase);
    }

    public IModFileProvider CreateProvider(string url, string modName)
    {
        // Extract .7z to temp directory, then wrap with FolderModFileProvider
        var tempDir = Path.Combine(Path.GetTempPath(), modName);
        SevenZipExtractor.Extract(url, tempDir);
        return new FolderModFileProvider(tempDir, modName);
    }

    public string GetFallbackModId(string url, string entryName)
    {
        return Path.GetFileNameWithoutExtension(entryName);
    }
}
```

===== DOC docs/resources/providers/index.md: Providers — `IModFileProvider` =====
A **provider** is the bottom layer of the resource pipeline. It gives read-only access to a single mod's files — text, binary, streams, directory listings — without the rest of the engine knowing whether those files come from a folder on disk, a `.zip` archive, or something else entirely.

## Interface

```csharp
public interface IModFileProvider : IDisposable
{
    string Mod { get; }                          // Short mod name for logging
    string RootPath { get; }                     // Path to the mod's root storage (folder or zip file)

    bool FileExists(string path);                // Check if a file exists
    IEnumerable<string> ListFiles(string directory = "");  // Shallow listing; dirs end with /
    string ReadFileText(string path);            // Read UTF-8 text, or null
    byte[] ReadFileBinary(string path);          // Read binary, or null
    Stream OpenStream(string path);              // Open a seekable stream
    string GetFileSystemPath(string path);       // Absolute path, or null (missing file / non-filesystem provider)
    string ResolveIncludePath(string includePath, string currentPath);  // Resolve relative includes
}
```

`IDisposable` is required because zip-based providers hold native OS file handles that must be released on mod unload. Folder providers implement `Dispose` as a no-op.

## Built-in providers

### `FolderModFileProvider`

Backed by a real directory on disk. This is the **only** provider type that supports `GetFileSystemPath` returning a non-null value — which means it is the only type that can serve Godot scenes via `ResourceLoader` and `PackedScene` instantiation.

```csharp
var provider = new FolderModFileProvider("mods/my_mod", "my_mod");
string text = provider.ReadFileText("manifest.json");
string fsPath = provider.GetFileSystemPath("scenes/battle.tscn");  // absolute path
```

Note that `GetFileSystemPath` returns **`null` when the file does not exist** —
not just for non-filesystem providers. Callers must handle the null return for
missing files as well.

### `ZipModFileProvider`

Backed by a `.zip` archive. On construction the entire entry table is flattened into a case-insensitive dictionary for O(1) lookups. `ListFiles` simulates directory listing by grouping flat zip entries.

```csharp
var provider = new ZipModFileProvider("mods/my_mod.zip", "my_mod");
string text = provider.ReadFileText("manifest.json");
string fsPath = provider.GetFileSystemPath("scenes/battle.tscn");  // always null
```

Limitations:
- `GetFileSystemPath` always returns `null` — Godot cannot load resources from inside a zip
- Callers that need scene instantiation must extract assets to the cache first (`ModCacheManager`)
- The zip stays open until `Dispose()` is called

## Adding a custom provider via a mod

A mod could provide a network-backed provider that streams files from a remote server:

```csharp
using System.Collections.Generic;
using System.IO;
using Cthangover.Core.Mods;

namespace MyMod
{
    public class HttpModFileProvider : IModFileProvider
    {
        private readonly HttpClient _client;
        private readonly string _baseUrl;

        public string Mod { get; }
        public string RootPath => null;

        public HttpModFileProvider(string baseUrl, string modName)
        {
            _baseUrl = baseUrl;
            Mod = modName;
            _client = new HttpClient();
        }

        public void Dispose()
        {
            _client?.Dispose();
        }

        public bool FileExists(string path)
        {
            var request = new HttpRequestMessage(HttpMethod.Head, $"{_baseUrl}/{path}");
            var response = _client.Send(request);
            return response.IsSuccessStatusCode;
        }

        public string ReadFileText(string path)
        {
            var response = _client.GetStringAsync($"{_baseUrl}/{path}").Result;
            return response;
        }

        // ... remaining methods ...
    }
}
```

To actually use a custom provider, you also need a custom `IModLoader` that creates it. See Loaders.

===== DOC docs/resources/scanners/index.md: Scanners — `IModScanner` =====
A **scanner** knows about a *source* of mods — a directory on disk, an HTTP catalog, a Steam Workshop subscription folder, or a Git repository. Each scanner declares its root URLs via `GetRoots()` and enumerates available mods via `Discover()`.

This replaces the old hardcoded `ResolveModsRoots()` method. Instead of the kernel knowing about `user://mods/` and `res://mods/`, each scanner declares its own roots.

## Interface

```csharp
public interface IModScanner : IModDiscoveryProvider
{
    string Name { get; }                          // "UserData", "GameDir", "HTTP Catalog", ...
    bool IsCanProvide(string url);                // Accessibility guard — does the root exist?
    IEnumerable<ModSourceRoot> GetRoots();        // Roots this scanner knows about, with priorities
    IEnumerable<DiscoveredModEntry> Discover(string rootUrl);  // Enumerate mods at a root
}
```

### `IsCanProvide` — the accessibility guard

Scanners always return their potential roots from `GetRoots()`. `IsCanProvide` acts as a filter — it is called on each root before discovery, and roots where it returns `false` are silently skipped. For filesystem scanners this means `Directory.Exists(url)`; for HTTP scanners it could mean a ping or health check.

This pattern avoids calling Godot APIs outside the runtime. When `GameEnvironment.IsRuntime` is `false` (editor, tests), the scanner returns a raw Godot path like `"user://mods/"` — `Directory.Exists("user://mods/")` returns `false`, so the root is skipped without ever touching `ProjectSettings.GlobalizePath`.

### `ModSourceRoot`

Each root carries a deduplication **priority**:

```csharp
public class ModSourceRoot
{
    public string Url { get; set; }      // Filesystem path or URL
    public int Priority { get; set; }    // Lower = higher precedence in dedup
}
```

### `DiscoveredModEntry`

```csharp
public class DiscoveredModEntry
{
    public string Url { get; set; }       // Full path/URL the loader will receive
    public string EntryName { get; set; } // Folder name, zip filename, etc.
}
```

## Built-in scanners

### `UserDataModScanner` — per-user mods

Scans the user-writable mods directory (`user://mods/`). Highest priority (0) — user-installed mods override everything else.

| Context | Root returned |
|---|---|
| Runtime (`GameEnvironment.IsRuntime = true`) | Globalised `user://mods/` path |
| Editor / tests | `"user://mods/"` (filtered by `IsCanProvide`) |

```csharp
var scanner = new UserDataModScanner();
var roots = scanner.GetRoots();
// Runtime:  [ { Url = "C:/Users/.../mods", Priority = 0 } ]
// Editor:   [ { Url = "user://mods/", Priority = 0 } ]  → filtered out
```

### `GameDirModScanner` — built-in mods

Scans the game's own mods directory. Lower priority than user mods at runtime — user mods with the same ID override built-in ones.

| Context | Root returned |
|---|---|
| Runtime | Globalised `res://mods/` path, priority 1 |
| Editor / tests (`GameEnvironment.IsRuntime == false`) | `"mods"` (project-relative), priority 0 |

Note that in editor/test environments this scanner's priority (0) equals `UserDataModScanner`'s — the runtime-only precedence difference does not apply outside the Godot runtime.

## Adding a custom scanner via a mod

A mod that publishes a community catalog over HTTP:

```csharp
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using Cthangover.Core.Mods.Discovery;

namespace CommunityCatalog
{
    public class CommunityCatalogScanner : IModScanner
    {
        private const string CatalogUrl = "https://community.example.com/catalog.json";

        public string Name => "Community Catalog";

        public bool IsCanProvide(string url)
        {
            // Check if server is reachable
            try
            {
                using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(3) };
                var response = client.Send(new HttpRequestMessage(HttpMethod.Head, url));
                return response.IsSuccessStatusCode;
            }
            catch
            {
                return false;
            }
        }

        public IEnumerable<ModSourceRoot> GetRoots()
        {
            yield return new ModSourceRoot
            {
                Url = CatalogUrl,
                Priority = 10  // Lower priority than local mods
            };
        }

        public IEnumerable<DiscoveredModEntry> Discover(string rootUrl)
        {
            using var client = new HttpClient();
            var json = client.GetStringAsync(rootUrl).Result;
            var entries = JsonSerializer.Deserialize<List<CatalogEntry>>(json);

            foreach (var entry in entries)
            {
                yield return new DiscoveredModEntry
                {
                    Url = entry.DownloadUrl,
                    EntryName = entry.Name
                };
            }
        }

        private class CatalogEntry
        {
            public string Name { get; set; }
            public string DownloadUrl { get; set; }
        }
    }
}
```

Drop this class in a mod DLL with `"sources": ["src/CommunityCatalogScanner.cs"]` in `manifest.json` — the kernel discovers it automatically.

You will also need a custom loader that knows how to download and open the zip files returned by this scanner. See Loaders.

===== DOC quickstart/en/index.md: Quick Start =====
Choose your path — each one leads to a working result.




Level 1 — Player
Modify character stats and add a new ability — without a single line of code.
Text editor · 5 minutes



Level 2 — Modder
Create your own scene with dialogue, choices, and transitions using the DSL scenario language.
JSON + DSL · 15 minutes



Level 3 — Programmer
Write a C# mod with a custom DSL action and settings in the menu.
C# + Godot API · 20 minutes




---

| | Level 1 | Level 2 | Level 3 |
|---|---|---|---|
| **C# needed** | No | No | Yes |
| **Godot needed** | No | No | No |
| **What we make** | Character patch | Scene & scenario | C# mod |
| **Difficulty** | ★☆☆☆☆ | ★★★☆☆ | ★★★★☆ |

===== DOC quickstart/en/modder.md: Level 2 — Modder: DSL Scene =====
JSON + DSL
15 minutes
No code


You'll create a mod with your own scene — a bar with dialogue, choices, and transitions. And you'll add an entrance to the bar to the existing town scene without touching the original files.

---

## Step 1: Mod structure

Create the folder and base files:

```filestree
mods/
└── quickstart_modder/
    ├── manifest.json
    ├── scenes/
    │   └── my_alko_bar.json              # Our new location — the booze bar!
    ├── scenarios/
    │   ├── my_alko_bar.scenario          # Scenario inside the bar, let the player interact with the new location
    │   └── town_entry_override.scenario  # Extend the town scene to add a transition to our new location
    ├── backgrounds/
    │   └── my_alko_bar/
    │       └── bg.png                    # Bar interior image
    └── sounds/
        └── locations/
            └── alkobar/
                └── bar_ambient.ogg       # Background bar noise sound
```

---

## Step 2: manifest.json

`mods/quickstart_modder/manifest.json`:

```jsonc
{
  "id": "quickstart_modder",                           // Mod ID
  "name": "My Booze Bar",                              // Mod name shown in the mod menu
  "description": "Adds a booze bar scene to the town", // Mod description shown in the mod menu
  "author": "modder",                                  // Mod author
  "depends": ["cthangover_game"]                     // Dependency on the core game (so manual mod load ordering is not required)
}
```

---

## Step 3: Scene definition

`mods/quickstart_modder/scenes/my_alko_bar.json`:

```jsonc
{
  "name": "my_alko_bar",                              // Create a new, unique scene identifier (used in switch_scene), this will be the new scene.
  "defaultBackground": "my_alko_bar/bg",              // Path to the PNG background inside backgrounds/ (without extension)
  "defaultAmbient": "locations/alkobar/bar_ambient",  // ID of the ambient sound for this location (path inside sounds/ without extension), e.g. "bar noise" that plays on entering the scene
  "defaultScenario": "scenarios/my_alko_bar.scenario" // Default scenario that will run in this scene if nothing else is specified
}
```

| Field | Description |
|---|---|
| `name` | Unique scene ID. Used in `switch_scene` |
| `defaultBackground` | Path to background inside `backgrounds/` (no extension) |
| `defaultAmbient` | Ambient sound: path inside `sounds/` without extension. Empty string — no sound |
| `defaultScenario` | Path to the `.scenario` file that runs on scene entry |

---

## Step 4: Background and sound

Place an image at `mods/quickstart_modder/backgrounds/my_alko_bar/bg.png`, and the ambient sound at `mods/quickstart_modder/sounds/locations/alkobar/bar_ambient.ogg`.

Any PNG image will work. The game supports shader-based lighting and time of day — the background will be automatically lit.

> If you don't have an image, temporarily copy `mods/cthangover_game/backgrounds/home/kitchen.png` → `mods/quickstart_modder/backgrounds/my_alko_bar/bg.png`. You can also skip the sound: set `"defaultAmbient": ""`.

---

## Step 5: DSL Scenario

`mods/quickstart_modder/scenarios/my_alko_bar.scenario`:

```scenario
scene: my_alko_bar
priority: 10
---
text "You walk into a half-empty bar."
text "A bored bartender stands behind the counter."

:menu
select "[What will you do?]"
option "Approach the counter" -> :bar
option "Leave" -> :leave

:bar
text "The bartender nods at you."
text "— What can I get you?"
goto -> :menu

:leave
switch_scene town_entry
end
```

### What's happening here

- **`scene:`** / **`priority:`** — the header: which scene the scenario belongs to and in what order it runs. The header is separated from the body by a `---` line.
- **`text`** — displays a line of dialogue. The `key=` parameter is a localization key (not needed here: without it the text itself is shown).
- **`select`** — opens a choice menu. The text in quotes is the prompt.
- **`option`** — a choice option. `-> :label` — where to jump when selected.
- **`:menu`**, **`:bar`**, **`:leave`** — labels for jumps.
- **`goto -> :menu`** — an unconditional jump: after talking to the bartender the menu opens again.
- **`switch_scene`** — moves the player to another scene.
- **`end`** — ends the scenario.

---

## Step 6: Extending the town (scene priority)

Now let's add an entrance to the bar to the town scene, **without touching** the original `cthangover_game` files.

`mods/quickstart_modder/scenarios/town_entry_override.scenario`:

```scenario
scene: town_entry
priority: 1
---
select "Music drifts out of the bar's half-open door..."
option "Go into the bar" -> :alko_bar
option "Walk past" -> :pass

:alko_bar
switch_scene my_alko_bar
end

:pass
end
```

### Why this works

The `town_entry` scene now has several scenarios:

| priority | Scenario | Mod |
|---|---|---|
| `1` | our `town_entry_override` | `quickstart_modder` |
| `10`–`30` | meeting Murakami (only while their `condition:` holds) | `cthangover_murakami` |
| `40` | the standard town menu | `cthangover_game` |

On entering a scene the engine runs the first eligible scenario in ascending `priority` order — starting from the **lowest**. When a scenario ends without changing the scene, the next one starts. So:

- "Go into the bar" — `switch_scene` takes the player to the bar;
- "Walk past" — our scenario simply ends (`end`), and everything continues as in the original game: Murakami's story scene or the standard town menu.

This way the mod adds something new without breaking anything. If our scenario drew the town menu itself, it would cover both the story and other mods.

> Learn more: Scene Priority System.

---

## Step 7: Connect the mod to the game

The game does not load every installed mod — only the mods that belong to the game's collection. Add your mod to it:

1. In the main menu, open **"Mods & Collections"** → the **"Collections"** tab.
2. On the **Cthangover** game card, click **"Edit scheme"**.
3. Select any mechanic on the scheme (for example, **"Story"**) and click **"Add mod"**.
4. Find your mod in the list (by the name from `manifest.json`) and click **"Add"**.
5. Click **"Save"** and confirm. Changes apply the next time the game starts.

---

## Step 8: Testing

Restart the game:

1. Go to the town — the first thing you see is the "Go into the bar" / "Walk past" choice.
2. Click "Go into the bar" — you're in your `my_alko_bar` scene with the background, bar noise, dialogue and choices.
3. Choose "Leave" — you return to the town.
4. Choose "Walk past" — the game continues as usual.

> **For developers.** From source you can open the scene right away, without playing through the prologue: `godot --path . -- --game=cthangover --new --with-mods=quickstart_modder --scene=town_entry`.

---

## What's next?

| Want to | Go to |
|---|---|
| Master all DSL commands | DSL Command Reference |
| Add interactive objects | Interactive Objects |
| Write a C# mod | Level 3 — Programmer |

---

## Download the finished example



Download quickstart_modder.zip


The downloadable mod's dialogue text is in Russian. Unpack the archive into `mods/quickstart_modder/` or put the `.zip` into `user://mods/`, then connect the mod to the game (step 7).

===== DOC quickstart/en/player.md: Level 1 — Player: Patching a Character =====
Text editor
5 minutes
No code


You'll buff the character Marao — increase health and attack, and add the "Stun" ability — using the patch system. Not a single line of code, just JSON.

---

## Step 1: Create the mod folder

Every mod lives in its own folder inside `mods/` (next to the game) or `user://mods/` (the user data folder; on Windows — `%APPDATA%\cthangover\mods`). Create:

```filestree
mods/
└── quickstart_player/
    ├── manifest.json
    └── patches/
        └── character.json
```

---

## Step 2: manifest.json

Every mod needs a manifest. Create `mods/quickstart_player/manifest.json`:

```jsonc
{
  "id": "quickstart_player",        // Unique mod identifier, use English
  "name": "Marao Buff",             // Display name shown in the mod management menu
  "description": "My first mod!",   // Description visible in the mod list and catalog
  "author": "player",               // Mod author (arbitrary string)
  "depends": ["cthangover_game"]  // Dependency on the core game (so manual mod load ordering is not required)
}
```

The manifest file itself is required — a mod without one is skipped — but no single field is mandatory: if `id` is omitted, the folder name is used as the mod id. Filling in `id` and `name` is still recommended. The game finds the mod folder by itself, but the mod only gets into the game after you connect it (step 4).

---

## Step 3: Character patch

Create `mods/quickstart_player/patches/character.json`:

```jsonc
{
  "Items": [
    {
      "Id": "Marao", // Character ID to patch (defined in the core mod)

      "Health": 5000, // New property values — overwrite the originals
      "Attack": 1200,
      "Point": 2,

      "Actions$add": ["physics/stun"] // $add appends elements to an array
    }
  ]
}
```

### How it works

- **`"Id": "Marao"`** — the patch target: character with identifier `Marao` (defined in the `cthangover_game` mod).
- **`"Health": 5000`**, **`"Attack": 1200`**, **`"Point": 2`** — overwrite the character's fields. New values replace old ones.
- **`"Actions$add": ["physics/stun"]`** — the `$add` suffix means "append to the array". The action `physics/stun` is added to the list. The default `physics/attack` and `physics/defence` remain. Stun costs 2 AP — that's why we raised `Point` too.

> Learn more about patches: Patches and Array Operations.

---

## Step 4: Connect the mod to the game

The game does not load every installed mod — only the mods that belong to the game's collection. Add your new mod to it once:

1. In the main menu, open **"Mods & Collections"** → the **"Collections"** tab.
2. On the **Cthangover** game card, click **"Edit scheme"**.
3. Select any mechanic on the scheme (for example, **"Story"**) and click **"Add mod"**.
4. Find your mod in the list (by the name from `manifest.json`) and click **"Add"**.
5. Click **"Save"** and confirm. Changes apply the next time the game starts.

> **For developers.** When running from source, you can connect the mod without the menu, with a flag: `godot --path . -- --game=cthangover --new --with-mods=quickstart_player`.

---

## Step 5: Run and check

Restart the game and start a new one. Enter any battle — Marao now has:

- **5000 HP** instead of 16
- **1200 ATK** instead of 5
- **2 AP** instead of 1
- A third action, **"Stun"**, next to "Basic attack" and "Defence"

---

## What's next?

| Want to | Go to |
|---|---|
| Create your own scene with dialogue | Level 2 — Modder |
| Understand patches in depth | Patches |
| Learn about characters | Characters |
| Write your first C# mod | Level 3 — Programmer |

---

## Download the finished example



Download quickstart_player.zip


The downloadable mod is in English. To install it, unpack the archive into the game's `mods/quickstart_player/` folder — or put the `.zip` itself into `user://mods/`: the game reads mods straight from archives, and mods in `user://mods/` take precedence over built-in mods with the same id. After installing, connect the mod to the game (step 4).

===== DOC quickstart/en/programmer.md: Level 3 — Programmer: C# Mod =====
C#
20 minutes
Roslyn compilation


You'll write a C# mod that adds a custom DSL action and menu settings to the game. The code is compiled on the fly via Roslyn when the game starts — you need neither Godot nor `dotnet build`.

---

## Step 1: Mod structure

```filestree
mods/
└── quickstart_programmer/
    ├── manifest.json
    ├── src/
    │   ├── MyModSettings.cs
    │   ├── MyMod.cs
    │   └── MyEffectAction.cs
    ├── scenarios/
    │   └── my_effect_demo.scenario
    └── locale/
        ├── ru.properties
        └── en.properties
```

---

## Step 2: manifest.json

`mods/quickstart_programmer/manifest.json`:

```jsonc
{
  "id": "quickstart_programmer",                // Mod ID
  "name": "My Code Mod",                        // Mod name shown in the mod list
  "description": "New action and mod settings", // Mod description shown in the mod list
  "sources": ["src/*.cs"],                      // Pattern for collecting source files (all ".cs" files)
  "author": "programmer",                       // Author
  "depends": ["cthangover_game"]              // Dependency on the core game (so manual mod load ordering is not required)
}
```

The `sources` field specifies which C# files to compile via Roslyn. Compilation errors (with file and line) go to the log under the `MODS_COMPILE` category.

---

## Step 3: Entry point — IModInitializer

`mods/quickstart_programmer/src/MyMod.cs`:

```csharp
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;

namespace Cthangover.MyCodeMod
{
    public class MyMod : IModInitializer
    {
        public void OnModLoaded(string modId) { }
        
        public void OnModResourcesReady()
        {
            
            GameLogger.Log("MY_MOD", "My mod loaded!", LogLevel.Message);
        }
        
    }
}
```

The `IModInitializer` interface is the lifecycle entry point of a mod's C# code; implementations are discovered via reflection. `OnModLoaded` is called when each mod loads (not only yours — `modId` holds the id of the mod that was loaded), `OnModResourcesReady` — once after all mods and their resources are loaded.

> Use `GameLogger.Log(...)` instead of `GD.Print(...)` if you want to work with logs.

---

## Step 4: Menu settings — IModSettings

`mods/quickstart_programmer/src/MyModSettings.cs`:

```csharp
using System.Collections.Generic;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;

public class MyModSettings : IModSettings
{
    private float _volume = 1.0f;
    private bool _enabled = true;

    public IReadOnlyList<ModSettingDefinition> GetDefinitions()
    {
        return new List<ModSettingDefinition>
        {
            new()
            {
                Key = "effect_volume",
                Name = "mymod/effect_volume",
                Type = SettingType.Slider,
                DefaultValue = "1.0",
                Min = 0f,
                Max = 2f,
                Step = 0.1f
            },
            new()
            {
                Key = "enable_mod",
                Name = "mymod/enable_mod",
                Type = SettingType.Bool,
                DefaultValue = "true"
            }
        };
    }

    public void WriteValues(DataBlob blob)
    {
        blob.SetFloat("effect_volume", _volume);
        blob.SetBool("enable_mod", _enabled);
    }

    public void ReadValues(DataBlob blob)
    {
        _volume = blob.GetFloat("effect_volume", 1.0f);
        _enabled = blob.GetBool("enable_mod", true);
    }
}
```

`GetDefinitions` describes the controls, `WriteValues`/`ReadValues` write and read the values symmetrically; the core takes care of saving to disk and of the UI. After the mod loads, your mod appears in "Settings" → the "Mod Settings" tab with a slider and a toggle.

---

## Step 5: Custom DSL action — IScenarioAction

Let's add a new action `my_effect`, available in `.scenario` files. `mods/quickstart_programmer/src/MyEffectAction.cs`:

```csharp
using Cthangover.Core.Actions;
using Cthangover.Core.Utils;

public class MyEffectAction : IScenarioAction
{
    public string Name => "my_effect";
    
    public void Run(IActionContext context)
    {
        GameLogger.Log("MY_MOD", "New action executed!", LogLevel.Message);
    }
}
```

Now in any `.scenario` file you can write:

```scenario
action my_effect
```

The engine will find your class via reflection and call `Run`. Through `context` you get dialog variables (`GetParam`) and game services: quests, characters, lighting, the scene.

---

## Step 6: A scenario to try it

To see the action in action, let's call it when the player steps out of the house. `mods/quickstart_programmer/scenarios/my_effect_demo.scenario`:

```scenario
scene: home_outside
priority: 0
---
action my_effect
end
```

`priority: 0` — our scenario runs before the standard one (`priority: 10`), and after `end` the game continues as usual: the scene opens its normal menu.

---

## Step 7: Localization

The setting labels are the localization keys from `Name`. `mods/quickstart_programmer/locale/ru.properties`:

```properties
mymod/effect_volume = Громкость эффекта
mymod/enable_mod = Включить мод
```

`mods/quickstart_programmer/locale/en.properties`:

```properties
mymod/effect_volume = Effect volume
mymod/enable_mod = Enable mod
```

---

## Step 8: Connect the mod to the game

The game does not load every installed mod — only the mods that belong to the game's collection:

1. In the main menu, open **"Mods & Collections"** → the **"Collections"** tab.
2. On the **Cthangover** game card, click **"Edit scheme"**.
3. Select any mechanic on the scheme (for example, **"Story"**) and click **"Add mod"**.
4. Find your mod in the list (by the name from `manifest.json`) and click **"Add"**.
5. Click **"Save"** and confirm. Changes apply the next time the game starts.

> **For developers.** From source, without the menu: `godot --path . -- --game=cthangover --new --with-mods=quickstart_programmer --scene=home_outside`.

---

## Step 9: Testing

Restart the game:

1. Open "Settings" → the "Mod Settings" tab → your mod with a slider and a toggle.
2. The game log (`user://logs/cthangover.log`, on Windows — `%APPDATA%\cthangover\logs\cthangover.log`) contains `[MY_MOD] My mod loaded!` after loading.
3. Step out of the house — `[MY_MOD] New action executed!` appears in the log.

---

## What's next?

| Want to | Go to |
|---|---|
| Subscribe to scene enter/exit | Scene Subscriptions |
| Create your own battle mode | Battle System |
| Build a tool window | Creating a Custom Tool |
| Write mods in GDScript | GDScript in Mods |
| All available interfaces | C# Source Code in Mods |

---

## Download the finished example



Download quickstart_programmer.zip


The downloadable mod is in English. Unpack the archive into `mods/quickstart_programmer/` or put the `.zip` into `user://mods/`, then connect the mod to the game (step 8).