Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md). All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
namespace Theriapolis.Core;
|
||||
|
||||
public static class C
|
||||
{
|
||||
// World map (the persistent continental grid)
|
||||
public const int WORLD_WIDTH_TILES = 256;
|
||||
public const int WORLD_HEIGHT_TILES = 256;
|
||||
public const int WORLD_TILE_PIXELS = 32; // px per world tile at 1:1 zoom
|
||||
|
||||
// Macro template (authored skeleton)
|
||||
public const int MACRO_GRID_WIDTH = 32;
|
||||
public const int MACRO_GRID_HEIGHT = 32;
|
||||
// => each macro cell covers WORLD_WIDTH_TILES/MACRO_GRID_WIDTH world tiles
|
||||
// (currently 256/32 = 8 tiles per cell)
|
||||
|
||||
// Tactical map (streamed)
|
||||
public const int TACTICAL_PER_WORLD_TILE = 32; // 1 tactical tile == 1 world pixel
|
||||
public const int TACTICAL_CHUNK_SIZE = 64; // tactical tiles per chunk side
|
||||
public const int TACTICAL_WINDOW_WORLD_TILES = 3; // 3x3 world tiles kept live
|
||||
|
||||
// Generation
|
||||
public const int WORLDGEN_BUDGET_SECONDS = 60;
|
||||
|
||||
// Border distortion — coastal domain-warp band (tiles deep on each side of land/ocean boundary)
|
||||
public const int COAST_BAND_WIDTH = 12;
|
||||
|
||||
// ElevationGen — continent mask domain-warp amplitude (tiles).
|
||||
// Displaces the ellipse falloff coordinates by up to this many tiles,
|
||||
// creating organic coastal excursions instead of a smooth ellipse edge.
|
||||
public const float COAST_WARP_AMP = 45f;
|
||||
|
||||
// ElevationGen — macro-cell border warp (Addendum A §1 primary mechanism).
|
||||
// Displaces the per-tile macro-cell lookup position by a smooth noise
|
||||
// field so macro cell boundaries become wiggly curves instead of
|
||||
// grid-aligned lines. MACRO_WARP_AMPLITUDE is the maximum displacement
|
||||
// in tiles; MACRO_WARP_FREQUENCY is cycles per tile of the warp noise.
|
||||
// With amplitude 24 and frequency 0.012 (period ≈ 83 tiles), macro cell
|
||||
// boundaries wobble by up to ~¾ of a cell's width over multi-cell scales,
|
||||
// which (combined with the soft macro clamp in ElevationGenStage) is
|
||||
// enough to dissolve the grid-aligned rectangular coastlines that the
|
||||
// hard clamp used to produce at mountain/ocean interfaces.
|
||||
public const float MACRO_WARP_AMPLITUDE = 24f;
|
||||
public const float MACRO_WARP_FREQUENCY = 0.012f;
|
||||
|
||||
// RNG sub-stream offset for continent-mask domain warp (must not collide with others)
|
||||
public const ulong RNG_COAST_WARP = 0xC4571UL;
|
||||
|
||||
// RNG sub-stream offset for macro-cell border warp
|
||||
public const ulong RNG_MACRO_WARP = 0xA1B2C3D4UL;
|
||||
|
||||
// WaterBodyClamp — minimum ocean tiles between continent and map edge
|
||||
public const int OCEAN_BORDER_WIDTH = 2;
|
||||
|
||||
// RNG sub-stream offsets (named, never collide)
|
||||
public const ulong RNG_TERRAIN = 0x7E22A11UL;
|
||||
public const ulong RNG_MOISTURE = 0xDEADBEEFUL;
|
||||
public const ulong RNG_TEMP = 0x7E39UL;
|
||||
public const ulong RNG_BORDER = 0xB07DE5UL;
|
||||
public const ulong RNG_COAST = 0xC0A57UL;
|
||||
public const ulong RNG_HYDRO = 0xD7A14A6EUL;
|
||||
public const ulong RNG_SETTLE = 0x5E771EUL;
|
||||
public const ulong RNG_ROAD = 0x7047EUL;
|
||||
public const ulong RNG_RAIL = 0x7A11UL;
|
||||
public const ulong RNG_FACTION = 0xFAC71074UL;
|
||||
public const ulong RNG_POI = 0x901F1UL;
|
||||
public const ulong RNG_WEATHER = 0x4EA7EUL;
|
||||
public const ulong RNG_TACTICAL = 0x7AC71CA1UL;
|
||||
|
||||
// ── Phase 2+3: Additional RNG sub-streams ──────────────────────────────
|
||||
public const ulong RNG_LAKE = 0x1A4EUL;
|
||||
public const ulong RNG_MEANDER = 0xE41DE7UL;
|
||||
public const ulong RNG_HABITAT = 0x4AB17A7UL;
|
||||
public const ulong RNG_ANCHOR = 0xA1C407UL;
|
||||
public const ulong RNG_SETTLE_ATTR = 0x5A774UL;
|
||||
public const ulong RNG_TRADE = 0x74ADE5UL;
|
||||
public const ulong RNG_ENCOUNTER = 0xE1C017UL;
|
||||
|
||||
// ── Phase 2: Hydrology ─────────────────────────────────────────────────
|
||||
public const int RIVER_MIN_FLOW_ACCUM = 1500; // tiles of upstream catchment to become a river
|
||||
public const int RIVER_MAX_COUNT = 150; // hard cap on total rivers to prevent perf explosion
|
||||
public const int RIVER_MAJOR_THRESHOLD = 8000; // flow accumulation for "major river"
|
||||
public const int RIVER_MODERATE_THRESHOLD = 2000; // flow accumulation for "river" (vs stream)
|
||||
public const float RIVER_CARVE_DEPTH = 0.02f; // elevation reduction along river paths
|
||||
public const int LAKE_MIN_AREA = 12; // tiles; smaller basins stay dry
|
||||
public const float MEANDER_AMP_FLAT = 5f; // max world-pixel lateral offset on plains
|
||||
public const float MEANDER_AMP_MOUNTAIN = 1.5f; // max world-pixel lateral offset in mountains
|
||||
public const float MEANDER_FREQ = 0.08f; // noise frequency for meander offset
|
||||
public const int SPLINE_SUBDIVISIONS = 4; // Catmull-Rom subdivisions per control point
|
||||
public const float RDP_TOLERANCE = 2.0f; // Ramer-Douglas-Peucker LOD tolerance (world px)
|
||||
|
||||
// ── Phase 3: Settlements ───────────────────────────────────────────────
|
||||
public const int SETTLE_TIER1_COUNT = 1;
|
||||
public const int SETTLE_TIER2_MIN = 4;
|
||||
public const int SETTLE_TIER2_MAX = 6;
|
||||
public const int SETTLE_TIER3_MIN = 15;
|
||||
public const int SETTLE_TIER3_MAX = 25;
|
||||
public const int SETTLE_TIER4_MIN = 40;
|
||||
public const int SETTLE_TIER4_MAX = 80;
|
||||
public const int SETTLE_TIER5_MIN = 100;
|
||||
public const int SETTLE_TIER5_MAX = 200;
|
||||
|
||||
// Tile-denominated minimum separations. Halved from their 512×512 baseline
|
||||
// (was 120/60/20/8/5 with ANCHOR_MIN_DIST=80) to densify the 256×256 map —
|
||||
// at the original spacing, ~60% of the map ran out of room for settlements
|
||||
// and Thornfield's constraint region often became unsatisfiable. Preserves
|
||||
// the SETTLE_TIER*_MIN/MAX counts, so the smaller world packs the same
|
||||
// target settlement population more tightly.
|
||||
public const int SETTLE_MIN_DIST_TIER1 = 60;
|
||||
public const int SETTLE_MIN_DIST_TIER2 = 30;
|
||||
public const int SETTLE_MIN_DIST_TIER3 = 10;
|
||||
public const int SETTLE_MIN_DIST_TIER4 = 4;
|
||||
public const int SETTLE_MIN_DIST_TIER5 = 3;
|
||||
|
||||
public const float ANCHOR_MIN_DIST = 40f;
|
||||
|
||||
// ── Phase 3: Infrastructure ────────────────────────────────────────────
|
||||
public const float ROAD_SHORTCUT_FRACTION = 0.30f;
|
||||
public const float BRIDGE_COST = 50f;
|
||||
public const float CROSSING_COST = 20f;
|
||||
public const float SETBACK_COST_SCALE = 8f;
|
||||
public const int SETBACK_DISTANCE = 4;
|
||||
public const float RAIL_BRIDGE_COST = 80f;
|
||||
|
||||
// Feature gate for the entire rail subsystem. When false, RailNetworkGenStage
|
||||
// early-returns, world.Rails stays empty, HasRail/RailroadAdjacent/RailDir
|
||||
// are never set, and no settlement gets HasRailStation = true. Downstream
|
||||
// consumers (road costs, cleanup, trade routes, rendering) all handle an
|
||||
// empty rail list gracefully, so this flag is a safe on/off switch.
|
||||
// static readonly (not const) so the guard evaluates at runtime — avoids
|
||||
// CS0162 unreachable-code warnings and prevents stale inlining in
|
||||
// downstream assemblies.
|
||||
public static readonly bool ENABLE_RAIL = false;
|
||||
|
||||
// Max deflection angle (degrees) allowed at any vertex of a rail tile path.
|
||||
// Heavy rail cars can't corner sharply, so the rail pipeline elides
|
||||
// vertices whose turn exceeds this cap when a passable shortcut exists.
|
||||
// 45° grid moves give turns of 0°, 45°, 90°, 135° — 75° permits only the
|
||||
// first two and forces 90°/135° corners to be smoothed.
|
||||
public const float MAX_RAIL_TURN_DEGREES = 75f;
|
||||
public const float EXISTING_ROAD_COST = 0.1f; // cost to travel an existing road tile (vs ~3–10 for new terrain)
|
||||
public const float EXISTING_RAIL_COST = 0.1f; // cost to travel an existing rail tile
|
||||
public const int SETTLEMENT_HALO_RADIUS = 1; // tiles: no existing-road/rail discount within this Chebyshev distance of path endpoints (prevents fan convergence)
|
||||
public const float SETTLEMENT_CONNECT_DIST = 64f; // world pixels (~2 tiles): max endpoint distance for a settlement to count as visually connected
|
||||
public const float BRIDGE_DECK_HALF_LENGTH = 10f; // world pixels walked along road from crossing to place deck ends
|
||||
|
||||
// ── Phase 3: Polyline Cleanup ─────────────────────────────────────────
|
||||
public const float POLYLINE_SNAP_ENDPOINT_DIST = 160f; // world pixels (~5 tiles): cluster nearby endpoints
|
||||
public const float POLYLINE_SNAP_BODY_DIST = 128f; // world pixels (~4 tiles): snap endpoint to polyline body (T-junction)
|
||||
public const float POLYLINE_MERGE_DIST = 80f; // world pixels (~2.5 tiles): merge parallel overlapping segments
|
||||
public const int POLYLINE_MAX_TRIM_POINTS = 20; // max points to search when trimming overshoots
|
||||
|
||||
// ── Phase 3: Factions ──────────────────────────────────────────────────
|
||||
public const float FACTION_INFLUENCE_RADIUS = 60f;
|
||||
public const float FACTION_DECAY_RATE = 0.015f;
|
||||
|
||||
// ── Phase 3: PoIs ──────────────────────────────────────────────────────
|
||||
public const int POI_MIN_DIST_FROM_SETTLE = 6;
|
||||
public const int POI_MIN_DIST_FROM_POI = 4;
|
||||
|
||||
// ── Phase 4: Tactical streaming ────────────────────────────────────────
|
||||
// Sub-streams of RNG_TACTICAL for the deterministic chunk passes. Each
|
||||
// chunk gen pass uses ForSubsystem(worldSeed ^ subStream ^ chunkHash)
|
||||
// so adjacent chunks see independent random scatters.
|
||||
public const ulong RNG_TACTICAL_GROUND = 0x7AC71C01UL;
|
||||
public const ulong RNG_TACTICAL_SCATTER = 0x7AC71C02UL;
|
||||
public const ulong RNG_TACTICAL_SPAWN = 0x7AC71C03UL;
|
||||
|
||||
// Chunk cache size. The 3×3 world-tile window typically touches at most
|
||||
// 9 chunks (each chunk = 2×2 world tiles at 32 tactical-per-world / 64 chunk side).
|
||||
// 16 gives a little slack so the player crossing a tile boundary doesn't
|
||||
// immediately evict + re-generate a chunk that was just visible.
|
||||
public const int CHUNK_CACHE_SOFT_MAX = 16;
|
||||
|
||||
// ── Phase 4: Actor + clock ─────────────────────────────────────────────
|
||||
// Travel time on the world map. With WORLD_TILE_PIXELS=32 and a road,
|
||||
// this is 8 * 0.5 = 4 in-game seconds per world pixel ≈ 128 sec/tile.
|
||||
// 256-tile world ≈ 32_768 sec across (~9h) on roads, ~18h cross-country.
|
||||
public const float BASE_SEC_PER_WORLD_PIXEL = 8f;
|
||||
public const float ROAD_SPEED_MULT = 0.5f;
|
||||
public const int TACTICAL_STEP_SECONDS = 10;
|
||||
public const ulong RNG_ACTOR_ID = 0xAC704DUL;
|
||||
|
||||
// World-pixel travel speed for the player on the world map (pixels per
|
||||
// second of real time). Independent of the in-game clock advancement;
|
||||
// this just controls how fast the sprite slides along the path.
|
||||
public const float PLAYER_TRAVEL_PX_PER_SEC = 80f;
|
||||
|
||||
// Tactical-mode WASD movement speed in tactical tiles per real second.
|
||||
// Continuous (sub-pixel) motion replaces the discrete one-tile step that
|
||||
// looked chunky at high zoom — at CAMERA_MAX_ZOOM=16, a 1-tile jump was
|
||||
// a visible 16-screen-pixel hop; now the player slides smoothly.
|
||||
// 40 px/sec is brisk-walk pace: at zoom 16 that's 640 screen px/sec
|
||||
// (half the window width per second), at zoom 3 (tactical threshold)
|
||||
// it's 120 screen px/sec.
|
||||
public const float TACTICAL_PLAYER_PX_PER_SEC = 3f;
|
||||
|
||||
// ── Phase 4: Save (bumped to v8 in Phase 7 M0) ────────────────────────
|
||||
public const int SAVE_SCHEMA_VERSION = 8;
|
||||
public const int SAVE_SLOT_COUNT = 10;
|
||||
/// <summary>
|
||||
/// Minimum readable save version. Below this, the loader refuses with
|
||||
/// "this save predates Phase 5 — start a new game from the same seed".
|
||||
/// Bump only when older saves can no longer be migrated meaningfully.
|
||||
/// v5 is still accepted (Phase 6 M2 adds an additive V5→V6 migration).
|
||||
/// </summary>
|
||||
public const int SAVE_SCHEMA_MIN_VERSION = 5;
|
||||
|
||||
// ── Phase 4: Player marker ─────────────────────────────────────────────
|
||||
// Player marker diameter, in *screen* pixels. The sprite renderer
|
||||
// counter-scales by 1/Zoom so the marker stays this size at every zoom
|
||||
// level — visible-but-not-huge on the world map, and not screen-filling
|
||||
// when the player walks around in tactical at CAMERA_MAX_ZOOM.
|
||||
public const int PLAYER_MARKER_SCREEN_PX = 48;
|
||||
|
||||
// ── Phase 4: Camera / zoom ─────────────────────────────────────────────
|
||||
// Zoom = screen pixels per world pixel. At Zoom=1, one tactical tile is
|
||||
// one screen pixel; world tiles (32 world px wide) span 32 screen pixels.
|
||||
//
|
||||
// CAMERA_TACTICAL_THRESHOLD = 32 — tactical kicks in exactly when each
|
||||
// tactical tile maps to TACTICAL_TILE_SPRITE_PX screen pixels, so the
|
||||
// 32×32 sprite art renders 1:1 at the threshold and upscales smoothly
|
||||
// up to CAMERA_MAX_ZOOM (3.125× upscale at full zoom).
|
||||
//
|
||||
// CAMERA_MAX_ZOOM = 100 — at 1280px window that's ~12.8 tactical tiles
|
||||
// visible across the screen at max zoom: comfortable for inspecting an
|
||||
// individual building or NPC.
|
||||
public const float CAMERA_MIN_ZOOM = 0.01f;
|
||||
public const float CAMERA_MAX_ZOOM = 100.0f;
|
||||
public const float CAMERA_TACTICAL_THRESHOLD = 32.0f;
|
||||
|
||||
// ── Phase 4: Tactical art ──────────────────────────────────────────────
|
||||
// Source resolution of every tactical tile sprite (surface + decoration).
|
||||
// The renderer scales each 32×32 source down to a 1×1 world-pixel cell so
|
||||
// the existing coord system ("1 tactical tile = 1 world pixel") stays
|
||||
// intact. With CAMERA_TACTICAL_THRESHOLD = 32, the sprite displays at
|
||||
// native 1:1 the moment tactical mode engages.
|
||||
public const int TACTICAL_TILE_SPRITE_PX = 32;
|
||||
|
||||
// ── Phase 5: RNG sub-streams ───────────────────────────────────────────
|
||||
public const ulong RNG_CHARACTER = 0xC4A2AC7EUL; // character creation rolls + starting equipment
|
||||
public const ulong RNG_STAT_ROLL = 0x57A7507UL; // 4d6-drop-lowest re-rolls in char creation
|
||||
public const ulong RNG_COMBAT = 0xC0B47UL; // per-encounter dice
|
||||
public const ulong RNG_NPC_SPAWN = 0xA7C2AUL; // per-NPC variation when instantiating from chunk spawn list
|
||||
public const ulong RNG_LOOT = 0x107EUL; // post-encounter drops
|
||||
|
||||
// ── Phase 5: Encounter triggering ─────────────────────────────────────
|
||||
// Hostile NPCs auto-trigger combat on LOS within this radius.
|
||||
public const int ENCOUNTER_TRIGGER_TILES = 8;
|
||||
// Friendly / Neutral NPCs show "[F] Talk to ..." prompt within this radius.
|
||||
public const int INTERACT_PROMPT_TILES = 2;
|
||||
// Encounter ends when all hostiles are out of sight + this far for this many turns.
|
||||
public const int ENCOUNTER_DISENGAGE_TILES = 16;
|
||||
public const int ENCOUNTER_DISENGAGE_TURNS = 3;
|
||||
|
||||
// ── Phase 5: Combat resolver ──────────────────────────────────────────
|
||||
public const int AC_FLOOR = 5;
|
||||
public const int AC_CEILING = 30;
|
||||
public const int HP_MAX = 999;
|
||||
public const int DEATH_SAVES_TO_DIE = 3;
|
||||
public const int DEATH_SAVES_TO_STABLE = 3;
|
||||
public const int CRIT_NATURAL = 20;
|
||||
|
||||
// ── Phase 5: Encumbrance ──────────────────────────────────────────────
|
||||
public const float ENCUMBRANCE_SOFT_MULT = 1.0f; // ≥1.0× capacity → speed -10ft
|
||||
public const float ENCUMBRANCE_HARD_MULT = 1.5f; // ≥1.5× capacity → speed halved + disadvantage
|
||||
|
||||
// ── Phase 5: Difficulty scaling (danger zones) ────────────────────────
|
||||
// Per-chunk DangerZone (0..4) drives which template each SpawnKind picks.
|
||||
public const int DANGER_DIST_FROM_START_PER_ZONE = 50; // tiles per +1 zone increment
|
||||
public const int DANGER_DIST_FROM_ROAD_THRESHOLD = 8; // further than this = +1 zone
|
||||
public const int DANGER_DIST_FROM_SETTLE_THRESHOLD = 16; // further than this = +1 zone
|
||||
public const int DANGER_ZONE_MIN = 0;
|
||||
public const int DANGER_ZONE_MAX = 4;
|
||||
|
||||
// ── Phase 5: Save (will bump SAVE_SCHEMA_VERSION to 5 in M2) ──────────
|
||||
public const string SAVE_SLOT_AUTOSAVE_COMBAT = "autosave_combat";
|
||||
|
||||
// ── Phase 6 M0: Settlement stamping ───────────────────────────────────
|
||||
/// <summary>SeededRng sub-stream for procedural Tier 2–5 layout rolls.</summary>
|
||||
public const ulong RNG_BUILDING_LAYOUT = 0xB1D106UL;
|
||||
|
||||
/// <summary>Smallest acceptable footprint dimension for a building template.</summary>
|
||||
public const int BUILDING_MIN_W_TILES = 4;
|
||||
public const int BUILDING_MIN_H_TILES = 3;
|
||||
|
||||
/// <summary>Don't stamp scatter or walls within this halo of any door tile.</summary>
|
||||
public const int BUILDING_DOOR_HALO_TILES = 2;
|
||||
|
||||
/// <summary>Minimum gap (in tiles) between adjacent procedural buildings.</summary>
|
||||
public const int SETTLEMENT_BUILDING_GAP_MIN = 2;
|
||||
|
||||
// ── Phase 6 M2: Reputation ────────────────────────────────────────────
|
||||
/// <summary>Minimum / maximum reputation value (per-faction and per-NPC personal disposition).</summary>
|
||||
public const int REP_MIN = -100;
|
||||
public const int REP_MAX = 100;
|
||||
|
||||
// Disposition tier thresholds — *inclusive lower bound* of each band so
|
||||
// a single ascending if-cascade picks them out via `score >= threshold`.
|
||||
// Per reputation.md §I-2 the bands are -100..-76 Nemesis, -75..-51
|
||||
// Hostile, -50..-26 Antagonistic, -25..-1 Unfriendly, 0 Neutral,
|
||||
// +1..+25 Favorable, +26..+50 Friendly, +51..+75 Allied, +76..+100 Champion.
|
||||
public const int REP_HOSTILE_THRESHOLD = -75;
|
||||
public const int REP_ANTAGONISTIC_THRESHOLD = -50;
|
||||
public const int REP_UNFRIENDLY_THRESHOLD = -25;
|
||||
public const int REP_FAVORABLE_THRESHOLD = 1;
|
||||
public const int REP_FRIENDLY_THRESHOLD = 26;
|
||||
public const int REP_ALLIED_THRESHOLD = 51;
|
||||
public const int REP_CHAMPION_THRESHOLD = 76;
|
||||
|
||||
// ── Phase 6 M5: Reputation propagation ────────────────────────────────
|
||||
/// <summary>SeededRng sub-stream for propagation coin-flips (frontier delivery).</summary>
|
||||
public const ulong RNG_REP_PROPAGATION = 0x1EFA77UL;
|
||||
|
||||
// Distance-band radii in world tiles. Zero (origin) = full magnitude.
|
||||
public const int REP_ADJACENT_DIST_TILES = 20;
|
||||
public const int REP_REGIONAL_DIST_TILES = 60;
|
||||
public const int REP_CONTINENTAL_DIST_TILES = 200;
|
||||
|
||||
// Decay multipliers per band, expressed as integer percentages.
|
||||
public const int REP_DECAY_AT_ORIGIN_PCT = 100;
|
||||
public const int REP_DECAY_ADJACENT_PCT = 80;
|
||||
public const int REP_DECAY_REGIONAL_PCT = 60;
|
||||
public const int REP_DECAY_CONTINENTAL_PCT = 40;
|
||||
public const int REP_DECAY_FRONTIER_PCT = 20;
|
||||
|
||||
/// <summary>Coin-flip probability that a frontier settlement actually receives the news at all.</summary>
|
||||
public const int REP_FRONTIER_DELIVERY_PROB_PCT = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Magnitudes at or above this threshold (positive or negative) bypass
|
||||
/// distance decay AND frontier coin-flips: NEMESIS / CHAMPION events
|
||||
/// propagate at full magnitude, continent-wide, immediately. Per
|
||||
/// reputation.md §I-2.
|
||||
/// </summary>
|
||||
public const int REP_EXTREME_BYPASS_MAGNITUDE = 50;
|
||||
|
||||
// ── Phase 6 M3: Dialogue ──────────────────────────────────────────────
|
||||
/// <summary>SeededRng sub-stream for in-dialogue skill-check rolls.</summary>
|
||||
public const ulong RNG_DIALOGUE = 0xD1A106EUL;
|
||||
|
||||
/// <summary>Cap on options shown per dialogue node.</summary>
|
||||
public const int DIALOGUE_MAX_OPTIONS_PER_NODE = 6;
|
||||
|
||||
/// <summary>Lines of scrollback retained inside the dialogue panel.</summary>
|
||||
public const int DIALOGUE_HISTORY_LINES = 50;
|
||||
|
||||
// ── Phase 6 M4: Quests ────────────────────────────────────────────────
|
||||
/// <summary>SeededRng sub-stream for quest-step random outcomes.</summary>
|
||||
public const ulong RNG_QUEST = 0x9E57E0UL;
|
||||
|
||||
/// <summary>Sanity cap on simultaneously active quests.</summary>
|
||||
public const int QUEST_MAX_ACTIVE = 20;
|
||||
|
||||
/// <summary>Cap on completed-quest history shown in the journal.</summary>
|
||||
public const int QUEST_LOG_COMPLETED_LIMIT = 100;
|
||||
|
||||
/// <summary>Tile radius for "enter_anchor" quest triggers (matches plan §4.4).</summary>
|
||||
public const int QUEST_ENTER_ANCHOR_RADIUS_TILES = 4;
|
||||
|
||||
/// <summary>Tile radius for "enter_role_proximity" quest triggers.</summary>
|
||||
public const int QUEST_ENTER_ROLE_RADIUS_TILES = 2;
|
||||
|
||||
// ── Phase 6.5 M0: Levelling ───────────────────────────────────────────
|
||||
/// <summary>SeededRng sub-stream for HP rolls and other per-level random outcomes.</summary>
|
||||
public const ulong RNG_LEVELUP = 0x1E7E107UL;
|
||||
|
||||
/// <summary>
|
||||
/// Levels that grant an Ability Score Improvement choice (player picks
|
||||
/// +2 to one stat or +1 to two stats). Standard d20 schedule.
|
||||
/// The XP-to-next-level table itself lives in
|
||||
/// <see cref="Rules.Stats.XpTable.Threshold"/> (canonical, 1-indexed).
|
||||
/// </summary>
|
||||
public static readonly int[] ASI_LEVELS = new[] { 4, 8, 12, 16, 19 };
|
||||
|
||||
/// <summary>Level at which the player picks a subclass.</summary>
|
||||
public const int SUBCLASS_SELECTION_LEVEL = 3;
|
||||
|
||||
/// <summary>Hard cap on ability scores below level 20.</summary>
|
||||
public const int ABILITY_SCORE_CAP_PRE_L20 = 20;
|
||||
|
||||
/// <summary>Hard cap on ability scores at level 20 (a couple of capstone features push this).</summary>
|
||||
public const int ABILITY_SCORE_CAP_AT_L20 = 22;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum supported character level. Phase 6.5 wires the engine for
|
||||
/// 1..20 but only ships full mechanical effect for levels 1..15;
|
||||
/// levels 16..20 use the same machinery but feature-defs may stub.
|
||||
/// </summary>
|
||||
public const int CHARACTER_LEVEL_MAX = 20;
|
||||
|
||||
// ── Phase 6.5 M5: Hybrid passing detection ────────────────────────────
|
||||
/// <summary>SeededRng sub-stream for hybrid scent-detection rolls.</summary>
|
||||
public const ulong RNG_PASSING = 0x9A55E5UL;
|
||||
|
||||
/// <summary>WIS save DC the NPC rolls to detect a hybrid PC's scent.</summary>
|
||||
public const int HYBRID_DETECTION_DC = 12;
|
||||
|
||||
/// <summary>
|
||||
/// CHA Deception DC the PC's "I'm passing" counter-roll uses. Standard
|
||||
/// case: equal to the NPC's detection DC. <c>theriapolis-rpg-clades.md</c>
|
||||
/// notes a stricter DC for an even split — Phase 6.5 simplification:
|
||||
/// dominant lineage is always considered ≥ 80% expressive (the player
|
||||
/// chooses dominance and accepts that consequence).
|
||||
/// </summary>
|
||||
public const int HYBRID_DECEPTION_DC = 12;
|
||||
|
||||
// ── Phase 6.5 M7: Betrayal cascade magnitudes ─────────────────────────
|
||||
/// <summary>
|
||||
/// Threshold magnitude (inclusive) for a "minor" betrayal. Drives the
|
||||
/// cascade tier in <see cref="Rules.Reputation.BetrayalCascade"/> —
|
||||
/// any <see cref="Rules.Reputation.RepEventKind.Betrayal"/> with
|
||||
/// <c>magnitude < 0</c> picks the most severe matching tier
|
||||
/// (most-negative wins).
|
||||
/// </summary>
|
||||
public const int BETRAYAL_MAGNITUDE_MINOR = -10;
|
||||
public const int BETRAYAL_MAGNITUDE_MODERATE = -25;
|
||||
public const int BETRAYAL_MAGNITUDE_MAJOR = -50;
|
||||
public const int BETRAYAL_MAGNITUDE_CRITICAL = -75;
|
||||
|
||||
/// <summary>Faction-standing impact at each betrayal tier (signed; cascade through opposition).</summary>
|
||||
public const int BETRAYAL_FACTION_DELTA_MINOR = -5;
|
||||
public const int BETRAYAL_FACTION_DELTA_MODERATE = -15;
|
||||
public const int BETRAYAL_FACTION_DELTA_MAJOR = -30;
|
||||
public const int BETRAYAL_FACTION_DELTA_CRITICAL = -50;
|
||||
|
||||
// ── Phase 7: RNG sub-streams ──────────────────────────────────────────
|
||||
/// <summary>Per-PoI dungeon room-graph generation: room count, branching, special-room slots.</summary>
|
||||
public const ulong RNG_DUNGEON_LAYOUT = 0xD06E07AUL;
|
||||
/// <summary>Within a layout, picking which room template fills each role-eligible slot.</summary>
|
||||
public const ulong RNG_ROOM_PICK = 0x40072EUL;
|
||||
/// <summary>Per-room spawn selection (which NPC template fills each encounter slot).</summary>
|
||||
public const ulong RNG_DUNGEON_POPULATE = 0x70757UL;
|
||||
/// <summary>Per-container loot rolls. Distinct from <see cref="RNG_LOOT"/> (encounter drops).</summary>
|
||||
public const ulong RNG_DUNGEON_LOOT = 0xD0717EUL;
|
||||
|
||||
// ── Phase 7: Dungeon generation ───────────────────────────────────────
|
||||
public const int DUNGEON_SMALL_ROOMS_MIN = 3;
|
||||
public const int DUNGEON_SMALL_ROOMS_MAX = 5;
|
||||
public const int DUNGEON_MED_ROOMS_MIN = 6;
|
||||
public const int DUNGEON_MED_ROOMS_MAX = 10;
|
||||
public const int DUNGEON_LARGE_ROOMS_MIN = 11;
|
||||
public const int DUNGEON_LARGE_ROOMS_MAX = 20;
|
||||
/// <summary>Reject-and-retry ceiling on the layout-builder. Beyond this we fall back to a guaranteed-valid linear layout.</summary>
|
||||
public const int DUNGEON_LAYOUT_MAX_ATTEMPTS = 8;
|
||||
|
||||
/// <summary>Rooms snap their AABB to a multiple of this many tactical tiles.</summary>
|
||||
public const int ROOM_GRID_SNAP_TILES = 16;
|
||||
public const int ROOM_CORRIDOR_MIN_W = 2;
|
||||
public const int ROOM_CORRIDOR_MAX_W = 3;
|
||||
/// <summary>Minimum gap (in tiles) between adjacent rooms before a corridor is required.</summary>
|
||||
public const int ROOM_INTER_ROOM_GAP_TILES = 2;
|
||||
|
||||
/// <summary>Tactical-tile padding around the room-AABB union when sizing the dungeon's tile array.</summary>
|
||||
public const int DUNGEON_AABB_PADDING = 8;
|
||||
|
||||
// ── Phase 7: Loot band selection (PoI LevelBand → loot table tier) ────
|
||||
public const float LOOT_TABLE_BAND_T1_THRESHOLD = 0.0f; // level band 0-1 → t1
|
||||
public const float LOOT_TABLE_BAND_T2_THRESHOLD = 2.0f; // level band 2 → t2
|
||||
public const float LOOT_TABLE_BAND_T3_THRESHOLD = 3.0f; // level band 3 → t3
|
||||
|
||||
// ── Phase 7: Clade-responsive movement multipliers ────────────────────
|
||||
/// <summary>Soft mismatch (cervid antler clearance for Large PCs, bovid space for Small PCs).</summary>
|
||||
public const float MOVE_COST_MISMATCH_LIGHT = 1.2f;
|
||||
/// <summary>Medium mismatch (Med-Large PCs in Mustelid tunnels, Small PCs in Ursid halls).</summary>
|
||||
public const float MOVE_COST_MISMATCH_MED = 1.5f;
|
||||
/// <summary>Heavy mismatch / squeezing (Large PCs in Mustelid tunnels — the canonical example).</summary>
|
||||
public const float MOVE_COST_MISMATCH_HEAVY = 2.0f;
|
||||
|
||||
// ── Phase 7: Locked door + trap DCs ───────────────────────────────────
|
||||
public const int LOCK_DC_TRIVIAL = 10;
|
||||
public const int LOCK_DC_EASY = 12;
|
||||
public const int LOCK_DC_MEDIUM = 15;
|
||||
public const int LOCK_DC_HARD = 18;
|
||||
|
||||
public const int TRAP_DC_TRIVIAL = 10;
|
||||
public const int TRAP_DC_EASY = 12;
|
||||
public const int TRAP_DC_MEDIUM = 15;
|
||||
|
||||
/// <summary>Tripwire trap damage on disarm-fail: 1d6 piercing.</summary>
|
||||
public const int TRAP_DAMAGE_DICE_TRIPWIRE = 1;
|
||||
public const int TRAP_DAMAGE_DIE_TRIPWIRE = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Bonus XP awarded on full dungeon clear, expressed as a multiplier of
|
||||
/// the dungeon's largest single-NPC XpAward. 1.0 means "doubling the
|
||||
/// boss kill". Tunable post-playtest.
|
||||
/// </summary>
|
||||
public const float DUNGEON_CLEAR_XP_BONUS_FRACTION = 1.0f;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable background definition loaded from backgrounds.json.
|
||||
/// Phase 5 grants the listed skill / tool proficiencies but does not
|
||||
/// apply the named feature's mechanical effect — those resolve to
|
||||
/// dialogue / quest / faction systems shipped in Phase 6.
|
||||
/// </summary>
|
||||
public sealed record BackgroundDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("flavor")]
|
||||
public string Flavor { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("skill_proficiencies")]
|
||||
public string[] SkillProficiencies { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("tool_proficiencies")]
|
||||
public string[] ToolProficiencies { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("feature_name")]
|
||||
public string FeatureName { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("feature_description")]
|
||||
public string FeatureDescription { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("suggested_personality")]
|
||||
public string SuggestedPersonality { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 — pre-meeting prejudice template per
|
||||
/// <c>theriapolis-rpg-reputation.md §I-1</c>. Each NPC carries a
|
||||
/// <c>BiasProfileId</c> that points at one of these; the runtime
|
||||
/// disposition formula adds <see cref="CladeBias"/>[pc.clade] (plus the
|
||||
/// universal size-differential modifier) to the personal/faction
|
||||
/// components when computing how an NPC reacts to the player.
|
||||
///
|
||||
/// 12 profiles ship with the game: pack-loyal Canid traditionalists,
|
||||
/// herd-cautious Cervids, urban progressives, hybrid survivors, etc.
|
||||
/// Adding new profiles is a content-only edit.
|
||||
/// </summary>
|
||||
public sealed record BiasProfileDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
/// <summary>Modifier for the player's clade. Keys are clade ids ("canidae", "felidae", ...).</summary>
|
||||
[JsonPropertyName("clade_bias")]
|
||||
public Dictionary<string, int> CladeBias { get; init; } = new();
|
||||
|
||||
/// <summary>Modifier when the player is detected as hybrid. Negative = stigma, positive = solidarity.</summary>
|
||||
[JsonPropertyName("hybrid_bias")]
|
||||
public int HybridBias { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Optional faction-affinity hints. Map of faction id → +integer (faction
|
||||
/// the NPC favours) or -integer (faction the NPC distrusts). Phase 6 M5
|
||||
/// uses these to decide how an NPC reacts to the player's faction
|
||||
/// standing; M1/M2 only display them in the disposition tooltip.
|
||||
/// </summary>
|
||||
[JsonPropertyName("faction_affinity")]
|
||||
public Dictionary<string, int> FactionAffinity { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable biome definition loaded from biomes.json.
|
||||
/// Defines the biome's identity, visual representation, and the (e,m,t) ranges
|
||||
/// that can produce it during BiomeAssign.
|
||||
/// </summary>
|
||||
public sealed record BiomeDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("display_name")]
|
||||
public string DisplayName { get; init; } = "";
|
||||
|
||||
/// <summary>Single capital letter used in placeholder tile rendering.</summary>
|
||||
[JsonPropertyName("letter")]
|
||||
public char Letter { get; init; } = '?';
|
||||
|
||||
/// <summary>Hex color string (#RRGGBB) for the placeholder tile background.</summary>
|
||||
[JsonPropertyName("color")]
|
||||
public string Color { get; init; } = "#888888";
|
||||
|
||||
[JsonPropertyName("placeholder_sprite")]
|
||||
public string PlaceholderSprite { get; init; } = "";
|
||||
|
||||
// ── Assignment thresholds ────────────────────────────────────────────────
|
||||
// These are used only for "natural" biome assignment.
|
||||
// Ocean is handled separately (elevation < sea_level).
|
||||
[JsonPropertyName("elevation_min")] public float ElevationMin { get; init; } = 0f;
|
||||
[JsonPropertyName("elevation_max")] public float ElevationMax { get; init; } = 1f;
|
||||
[JsonPropertyName("moisture_min")] public float MoistureMin { get; init; } = 0f;
|
||||
[JsonPropertyName("moisture_max")] public float MoistureMax { get; init; } = 1f;
|
||||
[JsonPropertyName("temp_min")] public float TempMin { get; init; } = 0f;
|
||||
[JsonPropertyName("temp_max")] public float TempMax { get; init; } = 1f;
|
||||
|
||||
/// <summary>Priority — higher-priority biomes win when multiple match.</summary>
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; } = 0;
|
||||
|
||||
/// <summary>True if this is a transition/mixed biome (not assignable from base rules).</summary>
|
||||
[JsonPropertyName("is_transition")]
|
||||
public bool IsTransition { get; init; } = false;
|
||||
|
||||
// ── Parsed color cache ───────────────────────────────────────────────────
|
||||
private (byte R, byte G, byte B)? _parsedColor;
|
||||
|
||||
public (byte R, byte G, byte B) ParsedColor()
|
||||
{
|
||||
if (_parsedColor.HasValue) return _parsedColor.Value;
|
||||
string hex = Color.TrimStart('#');
|
||||
byte r = Convert.ToByte(hex[..2], 16);
|
||||
byte g = Convert.ToByte(hex[2..4], 16);
|
||||
byte b = Convert.ToByte(hex[4..6], 16);
|
||||
_parsedColor = (r, g, b);
|
||||
return _parsedColor.Value;
|
||||
}
|
||||
|
||||
/// <summary>How well this biome matches the given (e, m, t) values. Returns 0 if outside range.</summary>
|
||||
public float Score(float e, float m, float t)
|
||||
{
|
||||
if (e < ElevationMin || e > ElevationMax) return 0f;
|
||||
if (m < MoistureMin || m > MoistureMax) return 0f;
|
||||
if (t < TempMin || t > TempMax) return 0f;
|
||||
|
||||
// Score = how close the values are to the center of the range (prefer tighter fits)
|
||||
float eMid = (ElevationMin + ElevationMax) * 0.5f;
|
||||
float mMid = (MoistureMin + MoistureMax) * 0.5f;
|
||||
float tMid = (TempMin + TempMax) * 0.5f;
|
||||
|
||||
float eHalf = (ElevationMax - ElevationMin) * 0.5f + 0.001f;
|
||||
float mHalf = (MoistureMax - MoistureMin) * 0.5f + 0.001f;
|
||||
float tHalf = (TempMax - TempMin) * 0.5f + 0.001f;
|
||||
|
||||
float closeness = 1f - (MathF.Abs(e - eMid)/eHalf + MathF.Abs(m - mMid)/mHalf + MathF.Abs(t - tMid)/tHalf) / 3f;
|
||||
return closeness + Priority * 0.5f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M0 — definition of a single stampable building (inn, shop, house,
|
||||
/// magistrate, etc.). Loaded from <c>Content/Data/building_templates/*.json</c>.
|
||||
///
|
||||
/// A template describes:
|
||||
/// - The building's footprint in tactical tiles.
|
||||
/// - Where doors sit on the perimeter.
|
||||
/// - Which interior cells get specific furniture (counter, bed, hearth, sign).
|
||||
/// - Which "roles" the building offers (innkeeper, shopkeeper, guard) and
|
||||
/// where each role's resident NPC stands when the player walks in.
|
||||
///
|
||||
/// Stamping draws walls along the perimeter, floors inside, doors at the
|
||||
/// declared door cells, and decorations at the declared deco cells. Spawn
|
||||
/// records for roles are emitted into the chunk's <see cref="Tactical.TacticalSpawn"/>
|
||||
/// list as <see cref="Tactical.SpawnKind.Resident"/> (Phase 6 M1).
|
||||
/// </summary>
|
||||
public sealed record BuildingTemplateDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Footprint width in tactical tiles. Includes perimeter walls.</summary>
|
||||
[JsonPropertyName("footprint_w_tiles")]
|
||||
public int FootprintWTiles { get; init; } = 1;
|
||||
|
||||
/// <summary>Footprint height in tactical tiles. Includes perimeter walls.</summary>
|
||||
[JsonPropertyName("footprint_h_tiles")]
|
||||
public int FootprintHTiles { get; init; } = 1;
|
||||
|
||||
/// <summary>Lowest settlement tier this template is eligible for (4 = village+).</summary>
|
||||
[JsonPropertyName("min_tier_eligible")]
|
||||
public int MinTierEligible { get; init; } = 5;
|
||||
|
||||
/// <summary>Door positions in template-local coords (0..W-1, 0..H-1).</summary>
|
||||
[JsonPropertyName("doors")]
|
||||
public BuildingDoor[] Doors { get; init; } = Array.Empty<BuildingDoor>();
|
||||
|
||||
/// <summary>Decorations in template-local coords.</summary>
|
||||
[JsonPropertyName("decos")]
|
||||
public BuildingDecoPlacement[] Decos { get; init; } = Array.Empty<BuildingDecoPlacement>();
|
||||
|
||||
/// <summary>Resident roles (innkeeper, shopkeeper, guard, ...).</summary>
|
||||
[JsonPropertyName("roles")]
|
||||
public BuildingRole[] Roles { get; init; } = Array.Empty<BuildingRole>();
|
||||
|
||||
/// <summary>
|
||||
/// Optional biome filter. Empty = eligible everywhere. Otherwise the
|
||||
/// settlement's home tile must be one of these biome ids.
|
||||
/// </summary>
|
||||
[JsonPropertyName("biome_filter")]
|
||||
public string[] BiomeFilter { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Selection weight in procedural Tier 2–5 layout rolls. Default 1.0.</summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public float Weight { get; init; } = 1f;
|
||||
|
||||
/// <summary>"civic" / "shop" / "house" / "inn" / "infrastructure" — used by procedural layout role mix.</summary>
|
||||
[JsonPropertyName("category")]
|
||||
public string Category { get; init; } = "house";
|
||||
}
|
||||
|
||||
public sealed record BuildingDoor
|
||||
{
|
||||
[JsonPropertyName("x")]
|
||||
public int X { get; init; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public int Y { get; init; }
|
||||
|
||||
/// <summary>Compass facing: "N" / "E" / "S" / "W". Door always sits on a perimeter cell.</summary>
|
||||
[JsonPropertyName("facing")]
|
||||
public string Facing { get; init; } = "S";
|
||||
}
|
||||
|
||||
public sealed record BuildingDecoPlacement
|
||||
{
|
||||
[JsonPropertyName("x")]
|
||||
public int X { get; init; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public int Y { get; init; }
|
||||
|
||||
/// <summary>Deco kind name: "counter" / "bed" / "hearth" / "sign".</summary>
|
||||
[JsonPropertyName("deco")]
|
||||
public string Deco { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record BuildingRole
|
||||
{
|
||||
/// <summary>Role tag inside the template (e.g. "innkeeper", "shopkeeper", "guard").</summary>
|
||||
[JsonPropertyName("tag")]
|
||||
public string Tag { get; init; } = "";
|
||||
|
||||
/// <summary>Spawn point in template-local coords. Must be an interior cell.</summary>
|
||||
[JsonPropertyName("spawn_at")]
|
||||
public int[] SpawnAt { get; init; } = new[] { 1, 1 };
|
||||
|
||||
/// <summary>True if this role may be omitted in a procedural layout (slot left empty).</summary>
|
||||
[JsonPropertyName("optional")]
|
||||
public bool Optional { get; init; } = false;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable clade (race-equivalent) record loaded from clades.json.
|
||||
/// Defines the broad biological family — Canidae, Felidae, etc. —
|
||||
/// plus the ability mods, traits, and detriments shared by all member
|
||||
/// species. See clades.md for the authoritative content.
|
||||
/// </summary>
|
||||
public sealed record CladeDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>STR/DEX/CON/INT/WIS/CHA → modifier (typically +1 each on two abilities).</summary>
|
||||
[JsonPropertyName("ability_mods")]
|
||||
public Dictionary<string, int> AbilityMods { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("traits")]
|
||||
public TraitDef[] Traits { get; init; } = Array.Empty<TraitDef>();
|
||||
|
||||
[JsonPropertyName("detriments")]
|
||||
public TraitDef[] Detriments { get; init; } = Array.Empty<TraitDef>();
|
||||
|
||||
[JsonPropertyName("languages")]
|
||||
public string[] Languages { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// "Predator" / "Prey" — surfaces in dialogue + faction-affinity logic
|
||||
/// (Phase 6) and gates a few class features in Phase 5 (e.g. Feral
|
||||
/// level-20 Apex Predator vs Apex Prey).
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = "predator";
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable class definition loaded from classes.json. Phase 5 reads
|
||||
/// every field — including the full level table — but only level-1
|
||||
/// features have runtime effect; higher-level entries are forward-compat
|
||||
/// scaffolding for the level-up flow shipped in Phase 5.5 / 6.
|
||||
/// </summary>
|
||||
public sealed record ClassDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Hit die size: 6 / 8 / 10 / 12.</summary>
|
||||
[JsonPropertyName("hit_die")]
|
||||
public int HitDie { get; init; } = 8;
|
||||
|
||||
/// <summary>Primary ability key(s) (STR / DEX / CON / INT / WIS / CHA).</summary>
|
||||
[JsonPropertyName("primary_ability")]
|
||||
public string[] PrimaryAbility { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Saving-throw proficiencies.</summary>
|
||||
[JsonPropertyName("saves")]
|
||||
public string[] Saves { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Armor proficiency tags: "light", "medium", "heavy", "shields".</summary>
|
||||
[JsonPropertyName("armor_proficiencies")]
|
||||
public string[] ArmorProficiencies { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Weapon proficiency tags: "simple", "martial", "natural", or specific item ids.</summary>
|
||||
[JsonPropertyName("weapon_proficiencies")]
|
||||
public string[] WeaponProficiencies { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Tool proficiency tags.</summary>
|
||||
[JsonPropertyName("tool_proficiencies")]
|
||||
public string[] ToolProficiencies { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("skills_choose")]
|
||||
public int SkillsChoose { get; init; } = 0;
|
||||
|
||||
[JsonPropertyName("skill_options")]
|
||||
public string[] SkillOptions { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Per-level entries. Level 1..20. Phase 5 only consults level 1, but
|
||||
/// the full table loads so the level-up flow doesn't need a schema bump.
|
||||
/// </summary>
|
||||
[JsonPropertyName("level_table")]
|
||||
public ClassLevelEntry[] LevelTable { get; init; } = Array.Empty<ClassLevelEntry>();
|
||||
|
||||
/// <summary>Description of each named feature referenced from level_table.</summary>
|
||||
[JsonPropertyName("feature_definitions")]
|
||||
public Dictionary<string, ClassFeatureDef> FeatureDefinitions { get; init; } = new();
|
||||
|
||||
/// <summary>Allowed subclass ids (cross-reference into subclasses.json).</summary>
|
||||
[JsonPropertyName("subclass_ids")]
|
||||
public string[] SubclassIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Items handed to a level-1 character of this class at creation time.
|
||||
/// <see cref="Rules.Character.CharacterBuilder"/> adds each entry to the
|
||||
/// inventory and, if <see cref="StartingKitItem.AutoEquip"/> is true,
|
||||
/// equips it into <see cref="StartingKitItem.EquipSlot"/>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("starting_kit")]
|
||||
public StartingKitItem[] StartingKit { get; init; } = Array.Empty<StartingKitItem>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row in <see cref="ClassDef.StartingKit"/>: the item id, quantity, and
|
||||
/// optional auto-equip target. ItemId must resolve against items.json.
|
||||
/// </summary>
|
||||
public sealed record StartingKitItem
|
||||
{
|
||||
[JsonPropertyName("item_id")]
|
||||
public string ItemId { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("qty")]
|
||||
public int Qty { get; init; } = 1;
|
||||
|
||||
/// <summary>If true, the item is equipped into <see cref="EquipSlot"/> at creation.</summary>
|
||||
[JsonPropertyName("auto_equip")]
|
||||
public bool AutoEquip { get; init; } = false;
|
||||
|
||||
/// <summary>"main_hand" / "off_hand" / "body" / "helm" / "cloak" / "boots" / "adaptive_pack" / etc.</summary>
|
||||
[JsonPropertyName("equip_slot")]
|
||||
public string EquipSlot { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ClassLevelEntry
|
||||
{
|
||||
[JsonPropertyName("level")]
|
||||
public int Level { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("prof")]
|
||||
public int ProficiencyBonus { get; init; } = 2;
|
||||
|
||||
/// <summary>Feature ids unlocked at this level. Resolves into <see cref="ClassDef.FeatureDefinitions"/>.</summary>
|
||||
[JsonPropertyName("features")]
|
||||
public string[] Features { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed record ClassFeatureDef
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
/// <summary>"passive", "active", "choice", "bonus_action", "reaction", "stub".</summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = "passive";
|
||||
|
||||
[JsonPropertyName("uses_per_short_rest")]
|
||||
public int? UsesPerShortRest { get; init; }
|
||||
|
||||
[JsonPropertyName("uses_per_long_rest")]
|
||||
public int? UsesPerLongRest { get; init; }
|
||||
|
||||
/// <summary>For "choice" features: the available pick ids.</summary>
|
||||
[JsonPropertyName("options")]
|
||||
public string[]? Options { get; init; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-loaded content lookup tables. Constructing one calls every loader
|
||||
/// exactly once and indexes results by id, so subsequent
|
||||
/// <c>resolver.Clades["canidae"]</c> lookups are O(1).
|
||||
///
|
||||
/// Used by character creation, save/load (id → def resolution), and Phase 5 M5
|
||||
/// NPC instantiation. Shared across screens that need any combination of
|
||||
/// these tables.
|
||||
/// </summary>
|
||||
public sealed class ContentResolver
|
||||
{
|
||||
public IReadOnlyDictionary<string, CladeDef> Clades { get; }
|
||||
public IReadOnlyDictionary<string, SpeciesDef> Species { get; }
|
||||
public IReadOnlyDictionary<string, ClassDef> Classes { get; }
|
||||
public IReadOnlyDictionary<string, SubclassDef> Subclasses { get; }
|
||||
public IReadOnlyDictionary<string, BackgroundDef> Backgrounds { get; }
|
||||
public IReadOnlyDictionary<string, ItemDef> Items { get; }
|
||||
public IReadOnlyDictionary<string, LootTableDef> LootTables { get; }
|
||||
public NpcTemplateContent Npcs { get; }
|
||||
|
||||
/// <summary>Phase 6 M0 — building templates + settlement layouts.</summary>
|
||||
public SettlementContent Settlements { get; }
|
||||
|
||||
/// <summary>Phase 6 M1 — pre-meeting bias profiles per <c>reputation.md §I-1</c>.</summary>
|
||||
public IReadOnlyDictionary<string, BiasProfileDef> BiasProfiles { get; }
|
||||
|
||||
/// <summary>Phase 6 M2 — faction definitions including opposition matrix entries.</summary>
|
||||
public IReadOnlyDictionary<string, FactionDef> Factions { get; }
|
||||
|
||||
/// <summary>Phase 6 M1 — generic + named friendly/neutral resident templates.</summary>
|
||||
public IReadOnlyDictionary<string, ResidentTemplateDef> Residents { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 — fast lookup of named residents by anchor-prefixed role
|
||||
/// tag (e.g. "millhaven.innkeeper" → ResidentTemplateDef). Generic
|
||||
/// templates live in <see cref="Residents"/>; this index only holds
|
||||
/// the entries with <c>named: true</c>.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, ResidentTemplateDef> ResidentsByRoleTag { get; }
|
||||
|
||||
/// <summary>Phase 6 M3 — dialogue trees indexed by id.</summary>
|
||||
public IReadOnlyDictionary<string, DialogueDef> Dialogues { get; }
|
||||
|
||||
/// <summary>Phase 6 M4 — quest trees indexed by id.</summary>
|
||||
public IReadOnlyDictionary<string, QuestDef> Quests { get; }
|
||||
|
||||
/// <summary>Phase 7 M0 — room templates indexed by id (every dungeon type).</summary>
|
||||
public IReadOnlyDictionary<string, RoomTemplateDef> RoomTemplates { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M0 — room templates indexed by dungeon type (e.g. <c>imperium</c>
|
||||
/// → IList of all imperium-typed templates). Used by the layout matcher
|
||||
/// to filter candidates without a linear scan.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, IReadOnlyList<RoomTemplateDef>> RoomTemplatesByType { get; }
|
||||
|
||||
/// <summary>Phase 7 M0 — dungeon layouts indexed by id.</summary>
|
||||
public IReadOnlyDictionary<string, DungeonLayoutDef> DungeonLayouts { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M0 — anchor-locked layouts indexed by anchor id (e.g.
|
||||
/// <c>OldHowlMine</c> → the pinned 3-room layout). Procedural pipeline
|
||||
/// never picks anchor-locked layouts; the anchor resolver consults this
|
||||
/// dict directly.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, DungeonLayoutDef> DungeonLayoutsByAnchor { get; }
|
||||
|
||||
public ContentResolver(ContentLoader loader)
|
||||
{
|
||||
var clades = loader.LoadClades();
|
||||
Clades = clades.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
|
||||
var speciesArr = loader.LoadSpecies(clades);
|
||||
Species = speciesArr.ToDictionary(s => s.Id, StringComparer.OrdinalIgnoreCase);
|
||||
var classes = loader.LoadClasses();
|
||||
Classes = classes.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase);
|
||||
Subclasses = loader.LoadSubclasses(classes).ToDictionary(s => s.Id, StringComparer.OrdinalIgnoreCase);
|
||||
Backgrounds = loader.LoadBackgrounds().ToDictionary(b => b.Id, StringComparer.OrdinalIgnoreCase);
|
||||
var items = loader.LoadItems();
|
||||
Items = items.ToDictionary(i => i.Id, StringComparer.OrdinalIgnoreCase);
|
||||
LootTables = loader.LoadLootTables(items).ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Phase 6 M5 — factions loaded early so NpcTemplates can validate
|
||||
// their faction field references against the canonical list.
|
||||
var factionsArr = loader.LoadFactions();
|
||||
Factions = factionsArr.ToDictionary(f => f.Id, StringComparer.OrdinalIgnoreCase);
|
||||
Npcs = loader.LoadNpcTemplates(items, factionsArr);
|
||||
|
||||
// Phase 6 M0 — building/layout content.
|
||||
var buildings = loader.LoadBuildingTemplates();
|
||||
var layouts = loader.LoadSettlementLayouts(buildings);
|
||||
var byId = buildings.ToDictionary(b => b.Id, StringComparer.OrdinalIgnoreCase);
|
||||
var preset = layouts.Where(l => l.Kind == "preset")
|
||||
.ToDictionary(l => l.Anchor, StringComparer.OrdinalIgnoreCase);
|
||||
var proc = layouts.Where(l => l.Kind == "procedural")
|
||||
.ToDictionary(l => l.Tier);
|
||||
Settlements = new SettlementContent(byId, preset, proc);
|
||||
|
||||
// Phase 6 M1 — bias profiles + resident templates.
|
||||
// (factionsArr already loaded above for NpcTemplates validation.)
|
||||
var biasArr = loader.LoadBiasProfiles(clades, factionsArr);
|
||||
BiasProfiles = biasArr.ToDictionary(b => b.Id, StringComparer.OrdinalIgnoreCase);
|
||||
var residentArr = loader.LoadResidentTemplates(biasArr, clades, speciesArr, factionsArr);
|
||||
Residents = residentArr.ToDictionary(r => r.Id, StringComparer.OrdinalIgnoreCase);
|
||||
ResidentsByRoleTag = residentArr
|
||||
.Where(r => r.Named)
|
||||
.ToDictionary(r => r.RoleTag, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Phase 6 M3 — dialogue trees.
|
||||
Dialogues = loader.LoadDialogues(items)
|
||||
.ToDictionary(d => d.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Phase 6 M4 — quest trees.
|
||||
Quests = loader.LoadQuests(items)
|
||||
.ToDictionary(q => q.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Phase 7 M0 — room templates + dungeon layouts.
|
||||
var roomArr = loader.LoadRoomTemplates();
|
||||
RoomTemplates = roomArr.ToDictionary(r => r.Id, StringComparer.OrdinalIgnoreCase);
|
||||
RoomTemplatesByType = roomArr
|
||||
.GroupBy(r => r.Type, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<RoomTemplateDef>)g.ToArray(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var layoutArr = loader.LoadDungeonLayouts(roomArr, LootTables.Values.ToArray());
|
||||
DungeonLayouts = layoutArr.ToDictionary(l => l.Id, StringComparer.OrdinalIgnoreCase);
|
||||
DungeonLayoutsByAnchor = layoutArr
|
||||
.Where(l => !string.IsNullOrEmpty(l.Anchor))
|
||||
.ToDictionary(l => l.Anchor, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M3 — JSON-loaded dialogue tree. Each tree is a directed graph
|
||||
/// of nodes; the runner walks from <see cref="Root"/> per option choice.
|
||||
/// Nodes are addressed by a string id local to the tree.
|
||||
///
|
||||
/// Author convention: keep one tree per file in
|
||||
/// <c>Content/Data/dialogues/*.json</c>. <see cref="Id"/> matches the
|
||||
/// filename (sans extension) so authors can find the file by id.
|
||||
/// </summary>
|
||||
public sealed record DialogueDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>Starting node id when the dialogue opens.</summary>
|
||||
[JsonPropertyName("root")]
|
||||
public string Root { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("nodes")]
|
||||
public DialogueNodeDef[] Nodes { get; init; } = System.Array.Empty<DialogueNodeDef>();
|
||||
}
|
||||
|
||||
public sealed record DialogueNodeDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>"npc" / "pc" / "narration".</summary>
|
||||
[JsonPropertyName("speaker")]
|
||||
public string Speaker { get; init; } = "npc";
|
||||
|
||||
/// <summary>Display text. Supports placeholders {pc.name}, {npc.role}, {disposition_label}.</summary>
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; init; } = "";
|
||||
|
||||
/// <summary>Effects applied automatically when the runner enters this node.</summary>
|
||||
[JsonPropertyName("on_enter")]
|
||||
public DialogueEffectDef[] OnEnter { get; init; } = System.Array.Empty<DialogueEffectDef>();
|
||||
|
||||
[JsonPropertyName("options")]
|
||||
public DialogueOptionDef[] Options { get; init; } = System.Array.Empty<DialogueOptionDef>();
|
||||
}
|
||||
|
||||
public sealed record DialogueOptionDef
|
||||
{
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; init; } = "";
|
||||
|
||||
/// <summary>Visibility predicates. Option is hidden if any condition fails.</summary>
|
||||
[JsonPropertyName("conditions")]
|
||||
public DialogueConditionDef[] Conditions { get; init; } = System.Array.Empty<DialogueConditionDef>();
|
||||
|
||||
/// <summary>
|
||||
/// When set, selecting this option rolls the named skill against
|
||||
/// <see cref="DialogueSkillCheckDef.Dc"/>. The runner branches into
|
||||
/// <see cref="EffectsOnSuccess"/>+<see cref="NextOnSuccess"/> on success
|
||||
/// or <see cref="EffectsOnFailure"/>+<see cref="NextOnFailure"/> on
|
||||
/// failure. <see cref="Effects"/> and <see cref="Next"/> are ignored
|
||||
/// when a skill check is present.
|
||||
/// </summary>
|
||||
[JsonPropertyName("skill_check")]
|
||||
public DialogueSkillCheckDef? SkillCheck { get; init; }
|
||||
|
||||
/// <summary>Node id to jump to when this option is selected. Empty / "<end>" closes the dialogue.</summary>
|
||||
[JsonPropertyName("next")]
|
||||
public string Next { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("effects")]
|
||||
public DialogueEffectDef[] Effects { get; init; } = System.Array.Empty<DialogueEffectDef>();
|
||||
|
||||
[JsonPropertyName("next_on_success")]
|
||||
public string NextOnSuccess { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("next_on_failure")]
|
||||
public string NextOnFailure { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("effects_on_success")]
|
||||
public DialogueEffectDef[] EffectsOnSuccess { get; init; } = System.Array.Empty<DialogueEffectDef>();
|
||||
|
||||
[JsonPropertyName("effects_on_failure")]
|
||||
public DialogueEffectDef[] EffectsOnFailure { get; init; } = System.Array.Empty<DialogueEffectDef>();
|
||||
}
|
||||
|
||||
public sealed record DialogueSkillCheckDef
|
||||
{
|
||||
/// <summary>Skill id (snake_case, matches SkillId.FromJson — e.g. "intimidation", "persuasion").</summary>
|
||||
[JsonPropertyName("skill")]
|
||||
public string Skill { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("dc")]
|
||||
public int Dc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Visibility predicate evaluated when the option is offered.</summary>
|
||||
public sealed record DialogueConditionDef
|
||||
{
|
||||
/// <summary>
|
||||
/// One of: "rep_at_least", "rep_below", "has_item", "not_has_item",
|
||||
/// "has_flag", "not_has_flag", "ability_min".
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = "";
|
||||
|
||||
/// <summary>Faction id (rep_at_least / rep_below). Empty = effective disposition vs current NPC.</summary>
|
||||
[JsonPropertyName("faction")]
|
||||
public string Faction { get; init; } = "";
|
||||
|
||||
/// <summary>Item id (has_item / not_has_item).</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>Flag id (has_flag / not_has_flag).</summary>
|
||||
[JsonPropertyName("flag")]
|
||||
public string Flag { get; init; } = "";
|
||||
|
||||
/// <summary>Ability id (ability_min): "STR" / "DEX" / "CON" / "INT" / "WIS" / "CHA".</summary>
|
||||
[JsonPropertyName("ability")]
|
||||
public string Ability { get; init; } = "";
|
||||
|
||||
/// <summary>Numeric threshold for rep / ability. Inclusive lower bound for *_at_least and *_min.</summary>
|
||||
[JsonPropertyName("value")]
|
||||
public int Value { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Side effect applied on option selection (or on node enter).</summary>
|
||||
public sealed record DialogueEffectDef
|
||||
{
|
||||
/// <summary>
|
||||
/// One of: "set_flag", "clear_flag", "give_item", "take_item",
|
||||
/// "rep_event", "open_shop", "start_quest", "give_xp".
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = "";
|
||||
|
||||
/// <summary>Flag id for set_flag/clear_flag.</summary>
|
||||
[JsonPropertyName("flag")]
|
||||
public string Flag { get; init; } = "";
|
||||
|
||||
/// <summary>Integer value for set_flag (defaults to 1).</summary>
|
||||
[JsonPropertyName("value")]
|
||||
public int Value { get; init; } = 1;
|
||||
|
||||
/// <summary>Item id for give_item/take_item.</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>Quantity for give_item/take_item (defaults to 1).</summary>
|
||||
[JsonPropertyName("qty")]
|
||||
public int Qty { get; init; } = 1;
|
||||
|
||||
/// <summary>RepEvent payload for rep_event.</summary>
|
||||
[JsonPropertyName("event")]
|
||||
public DialogueRepEventDef? Event { get; init; }
|
||||
|
||||
/// <summary>Quest id for start_quest. Phase 6 M3 ignores; M4 wires the quest engine.</summary>
|
||||
[JsonPropertyName("quest")]
|
||||
public string Quest { get; init; } = "";
|
||||
|
||||
/// <summary>XP magnitude for give_xp.</summary>
|
||||
[JsonPropertyName("xp")]
|
||||
public int Xp { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DialogueRepEventDef
|
||||
{
|
||||
/// <summary>RepEventKind name: "Dialogue" / "Quest" / "Combat" / "Rescue" / "Betrayal" / "Gift" / "Trade" / "Aid" / "Crime" / "Misc".</summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = "Dialogue";
|
||||
|
||||
[JsonPropertyName("magnitude")]
|
||||
public int Magnitude { get; init; }
|
||||
|
||||
[JsonPropertyName("faction")]
|
||||
public string Faction { get; init; } = "";
|
||||
|
||||
/// <summary>If empty, defaults to the current NPC's role tag.</summary>
|
||||
[JsonPropertyName("role_tag")]
|
||||
public string RoleTag { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
public string Note { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M0 — per-dungeon-type rules for assembling rooms into a complete
|
||||
/// dungeon. Loaded from <c>Content/Data/dungeon_layouts/*.json</c>.
|
||||
///
|
||||
/// Each layout declares: which dungeon type + size band it covers, the
|
||||
/// room-count band, branching policy (linear / branching / loop), required
|
||||
/// vs optional special-room roles (entry / narrative / boss / loot /
|
||||
/// dead-end), and the mapping from PoI level-band → loot-table tier.
|
||||
///
|
||||
/// <see cref="ContentLoader.LoadDungeonLayouts"/> validates ranges,
|
||||
/// branching enum, and loot-table-per-band references against the loaded
|
||||
/// <c>loot_tables.json</c>.
|
||||
///
|
||||
/// Anchor-locked dungeons (Old Howl mine, Imperium Ruin showcase) ship as
|
||||
/// special layouts whose <see cref="Anchor"/> field is set — these
|
||||
/// override the procedural pipeline so the experience is identical across
|
||||
/// seeds.
|
||||
/// </summary>
|
||||
public sealed record DungeonLayoutDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Dungeon type: <c>ImperiumRuin</c>, <c>AbandonedMine</c>,
|
||||
/// <c>CultDen</c>, <c>NaturalCave</c>, <c>OvergrownSettlement</c>.
|
||||
/// Matches the <see cref="World.PoiType"/> enum names exactly.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dungeon_type")]
|
||||
public string DungeonType { get; init; } = "";
|
||||
|
||||
/// <summary>Size band: <c>small</c> / <c>medium</c> / <c>large</c>.</summary>
|
||||
[JsonPropertyName("size_band")]
|
||||
public string SizeBand { get; init; } = "small";
|
||||
|
||||
/// <summary>
|
||||
/// Optional anchor id. When set, this layout is the canonical fixed
|
||||
/// layout for the named anchor (Old Howl mine, Imperium Ruin showcase).
|
||||
/// The procedural pipeline never picks anchor-locked layouts via
|
||||
/// <see cref="DungeonType"/> + <see cref="SizeBand"/>; only the anchor
|
||||
/// resolver consumes them.
|
||||
/// </summary>
|
||||
[JsonPropertyName("anchor")]
|
||||
public string Anchor { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("room_count_min")]
|
||||
public int RoomCountMin { get; init; } = 3;
|
||||
|
||||
[JsonPropertyName("room_count_max")]
|
||||
public int RoomCountMax { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Branching policy: <c>linear</c> (each room connects to the previous;
|
||||
/// chain), <c>branching</c> (each room past entry connects to one prior
|
||||
/// room — variable degree), <c>loop</c> (branching plus one extra
|
||||
/// connection that closes a loop).
|
||||
/// </summary>
|
||||
[JsonPropertyName("branching")]
|
||||
public string Branching { get; init; } = "linear";
|
||||
|
||||
/// <summary>Special-room roles that must be present in any successful assembly.</summary>
|
||||
[JsonPropertyName("required_roles")]
|
||||
public string[] RequiredRoles { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Special-room roles eligible for inclusion if there's room left over.</summary>
|
||||
[JsonPropertyName("optional_roles")]
|
||||
public string[] OptionalRoles { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Map from loot-table band (<c>t1</c>/<c>t2</c>/<c>t3</c>) to a real
|
||||
/// loot-table id (e.g. <c>loot_dungeon_imperium_t2</c>). Looked up by
|
||||
/// the dungeon populator when filling container slots.
|
||||
/// </summary>
|
||||
[JsonPropertyName("loot_table_per_band")]
|
||||
public Dictionary<string, string> LootTablePerBand { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Spawn-kind distribution for filling generic encounter slots — keys
|
||||
/// are spawn-kind names (<c>PoiGuard</c> / <c>WildAnimal</c> /
|
||||
/// <c>Brigand</c>), values are weights that sum to ~1.0.
|
||||
/// </summary>
|
||||
[JsonPropertyName("spawn_kind_distribution")]
|
||||
public Dictionary<string, float> SpawnKindDistribution { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Map from PoI <c>LevelBand</c> (0..3) to a loot-table band
|
||||
/// (<c>t1</c>/<c>t2</c>/<c>t3</c>). Keys are stringified ints
|
||||
/// because <see cref="System.Text.Json"/> rejects integer dictionary
|
||||
/// keys without a custom converter.
|
||||
/// </summary>
|
||||
[JsonPropertyName("level_band_to_loot_band")]
|
||||
public Dictionary<string, string> LevelBandToLootBand { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional anchor-pinned room sequence: when <see cref="Anchor"/>
|
||||
/// is set, this array names the exact templates to use, in order.
|
||||
/// Empty for non-anchor layouts (procedural pipeline picks instead).
|
||||
/// </summary>
|
||||
[JsonPropertyName("pinned_rooms")]
|
||||
public PinnedRoomEntry[] PinnedRooms { get; init; } = Array.Empty<PinnedRoomEntry>();
|
||||
}
|
||||
|
||||
public sealed record PinnedRoomEntry
|
||||
{
|
||||
/// <summary>Room template id. Must reference a real <see cref="RoomTemplateDef"/>.</summary>
|
||||
[JsonPropertyName("template")]
|
||||
public string Template { get; init; } = "";
|
||||
|
||||
/// <summary>Role assigned to this room slot: entry / transit / narrative / loot / boss / dead-end.</summary>
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; init; } = "transit";
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Definition record for a named faction, loaded from factions.json.
|
||||
/// </summary>
|
||||
public sealed class FactionDef
|
||||
{
|
||||
/// <summary>Machine-readable id matching FactionId enum (e.g. "covenant_enforcers").</summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>Display name shown in UI.</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>Abbreviated name for tight spaces.</summary>
|
||||
public string ShortName { get; set; } = "";
|
||||
|
||||
/// <summary>Hex color string for map overlay (e.g. "#4455AA").</summary>
|
||||
public string Color { get; set; } = "#FFFFFF";
|
||||
|
||||
/// <summary>Brief description used in tooltips/codex.</summary>
|
||||
public string Description { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M2 — opposition multipliers per <c>reputation.md §I-2</c>.
|
||||
/// When the player gains <c>+N</c> with this faction, every entry
|
||||
/// <c>{ otherFactionId: m }</c> here applies <c>+N × m</c> to that other
|
||||
/// faction's standing. Multipliers are negative for rivals (helping
|
||||
/// Inheritors hurts you with Enforcers), positive for allies, 0 for
|
||||
/// neutrals. Asymmetry is by design — see the design doc.
|
||||
/// </summary>
|
||||
[JsonPropertyName("opposition")]
|
||||
public Dictionary<string, float> Opposition { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M2 — when true, this faction is hidden from the reputation
|
||||
/// screen until the player learns it exists (the Maw, in Act I climax).
|
||||
/// The faction still exists internally and accumulates standing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hidden")]
|
||||
public bool Hidden { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable item definition loaded from items.json. Covers weapons,
|
||||
/// armor, shields, consumables, adventuring gear, and natural-weapon
|
||||
/// enhancers. Phase 5 ships a curated subset focused on combat readiness;
|
||||
/// the remaining catalog from equipment.md fills in over later phases.
|
||||
/// </summary>
|
||||
public sealed record ItemDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>"weapon", "armor", "shield", "consumable", "gear", "natural_weapon_enhancer".</summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = "gear";
|
||||
|
||||
[JsonPropertyName("cost_fang")]
|
||||
public float CostFang { get; init; } = 0f;
|
||||
|
||||
[JsonPropertyName("weight_lb")]
|
||||
public float WeightLb { get; init; } = 0f;
|
||||
|
||||
/// <summary>Sizes this item is manufactured for: "small" / "medium" / "large".</summary>
|
||||
[JsonPropertyName("sizes")]
|
||||
public string[] Sizes { get; init; } = new[] { "medium" };
|
||||
|
||||
/// <summary>Free-text properties from equipment.md (e.g. "finesse", "light", "two_handed", "versatile", "heavy", "loading", "thrown", "reach", "ammunition").</summary>
|
||||
[JsonPropertyName("properties")]
|
||||
public string[] Properties { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Free-text description for tooltips and codex.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
// ── Weapon fields (kind = "weapon") ─────────────────────────────────
|
||||
/// <summary>Weapon proficiency category: "simple", "martial", "natural", "firearm".</summary>
|
||||
[JsonPropertyName("proficiency")]
|
||||
public string Proficiency { get; init; } = "";
|
||||
|
||||
/// <summary>Damage dice expression (e.g. "1d6", "2d6", "1d8+2"). Empty for non-weapons.</summary>
|
||||
[JsonPropertyName("damage")]
|
||||
public string Damage { get; init; } = "";
|
||||
|
||||
/// <summary>Versatile two-handed damage dice (e.g. "1d10" when used two-handed). Empty if not versatile.</summary>
|
||||
[JsonPropertyName("damage_versatile")]
|
||||
public string DamageVersatile { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("damage_type")]
|
||||
public string DamageType { get; init; } = "";
|
||||
|
||||
/// <summary>Melee reach in tactical tiles. 0 / unset = default (1 for M, 2 for L).</summary>
|
||||
[JsonPropertyName("reach_tiles")]
|
||||
public int ReachTiles { get; init; } = 0;
|
||||
|
||||
/// <summary>Ranged: short / long ranges in tactical tiles. (0,0) for melee.</summary>
|
||||
[JsonPropertyName("range_short_tiles")]
|
||||
public int RangeShortTiles { get; init; } = 0;
|
||||
|
||||
[JsonPropertyName("range_long_tiles")]
|
||||
public int RangeLongTiles { get; init; } = 0;
|
||||
|
||||
// ── Armor / shield fields (kind = "armor" | "shield") ──────────────
|
||||
/// <summary>Base AC value (armor) or AC bonus (shield).</summary>
|
||||
[JsonPropertyName("ac_base")]
|
||||
public int AcBase { get; init; } = 0;
|
||||
|
||||
/// <summary>Max DEX modifier added to AC (medium = 2, heavy = 0). -1 = unlimited.</summary>
|
||||
[JsonPropertyName("ac_max_dex")]
|
||||
public int AcMaxDex { get; init; } = -1;
|
||||
|
||||
/// <summary>"light", "medium", "heavy" — for armor only.</summary>
|
||||
[JsonPropertyName("armor_class")]
|
||||
public string ArmorClass { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("min_str")]
|
||||
public int MinStr { get; init; } = 0;
|
||||
|
||||
[JsonPropertyName("stealth_disadvantage")]
|
||||
public bool StealthDisadvantage { get; init; } = false;
|
||||
|
||||
// ── Natural-weapon-enhancer fields (kind = "natural_weapon_enhancer") ─
|
||||
/// <summary>Which natural-weapon location this enhancer attaches to: "fang", "claw", "hoof", "antler", "horn", "tail".</summary>
|
||||
[JsonPropertyName("enhancer_slot")]
|
||||
public string EnhancerSlot { get; init; } = "";
|
||||
|
||||
/// <summary>Damage modifier added to the natural attack (e.g. +1 or +2).</summary>
|
||||
[JsonPropertyName("damage_bonus")]
|
||||
public int DamageBonus { get; init; } = 0;
|
||||
|
||||
/// <summary>Clades this enhancer is fitted for. Empty = universal.</summary>
|
||||
[JsonPropertyName("clade_fit")]
|
||||
public string[] CladeFit { get; init; } = Array.Empty<string>();
|
||||
|
||||
// ── Consumable fields (kind = "consumable") ─────────────────────────
|
||||
/// <summary>"healing", "poison", "pheromone", "performance", "scent_mask", etc.</summary>
|
||||
[JsonPropertyName("consumable_kind")]
|
||||
public string ConsumableKind { get; init; } = "";
|
||||
|
||||
/// <summary>Healing dice expression for healing consumables (e.g. "1d4", "2d6").</summary>
|
||||
[JsonPropertyName("healing")]
|
||||
public string Healing { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Loot table — list of weighted drop entries rolled when an NPC with
|
||||
/// matching <see cref="NpcTemplateDef.LootTable"/> id falls in combat.
|
||||
/// Each drop entry rolls independently against its <see cref="LootDrop.Chance"/>;
|
||||
/// successful drops contribute (qty_min..qty_max) of the item.
|
||||
///
|
||||
/// Phase 5 M6 keeps this stingy by design — most level-1 fights net 1-3
|
||||
/// items at most, never enough to obsolete the starting kit.
|
||||
/// </summary>
|
||||
public sealed record LootTableDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("drops")]
|
||||
public LootDrop[] Drops { get; init; } = System.Array.Empty<LootDrop>();
|
||||
}
|
||||
|
||||
public sealed record LootDrop
|
||||
{
|
||||
[JsonPropertyName("item_id")]
|
||||
public string ItemId { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("qty_min")]
|
||||
public int QtyMin { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("qty_max")]
|
||||
public int QtyMax { get; init; } = 1;
|
||||
|
||||
/// <summary>0..1 probability this drop fires. Independent of other drops in the table.</summary>
|
||||
[JsonPropertyName("chance")]
|
||||
public float Chance { get; init; } = 1.0f;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// A single cell in the 32×32 authored macro-template grid.
|
||||
/// Defines the biome character and generation constraints for a 32×32-tile region.
|
||||
/// </summary>
|
||||
public sealed class MacroCell
|
||||
{
|
||||
[JsonPropertyName("biome_type")]
|
||||
public string BiomeType { get; set; } = "temperate_grassland";
|
||||
|
||||
[JsonPropertyName("clade_affinities")]
|
||||
public string[] CladeAffinities { get; set; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("development")]
|
||||
public string Development { get; set; } = "agricultural";
|
||||
|
||||
/// <summary>strong | moderate | weak | nominal</summary>
|
||||
[JsonPropertyName("covenant")]
|
||||
public string Covenant { get; set; } = "moderate";
|
||||
|
||||
// Elevation constraints (0–1 range). Floor = minimum, Ceiling = maximum.
|
||||
[JsonPropertyName("elevation_floor")]
|
||||
public float ElevationFloor { get; set; } = 0f;
|
||||
|
||||
[JsonPropertyName("elevation_ceiling")]
|
||||
public float ElevationCeiling { get; set; } = 1f;
|
||||
|
||||
// Moisture constraints
|
||||
[JsonPropertyName("moisture_floor")]
|
||||
public float MoistureFloor { get; set; } = 0f;
|
||||
|
||||
[JsonPropertyName("moisture_ceiling")]
|
||||
public float MoistureCeiling { get; set; } = 1f;
|
||||
|
||||
// Temperature modifiers (added to base latitude temperature)
|
||||
[JsonPropertyName("temp_modifier")]
|
||||
public float TempModifier { get; set; } = 0f;
|
||||
}
|
||||
|
||||
/// <summary>Root structure of macro_template.json.</summary>
|
||||
public sealed class MacroTemplate
|
||||
{
|
||||
[JsonPropertyName("width")]
|
||||
public int Width { get; set; } = C.MACRO_GRID_WIDTH;
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
public int Height { get; set; } = C.MACRO_GRID_HEIGHT;
|
||||
|
||||
[JsonPropertyName("default_cell")]
|
||||
public MacroCell DefaultCell { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("regions")]
|
||||
public MacroRegion[] Regions { get; set; } = Array.Empty<MacroRegion>();
|
||||
|
||||
/// <summary>Expand regions into a flat [width, height] grid. Later regions overwrite earlier ones.</summary>
|
||||
public MacroCell[,] Build()
|
||||
{
|
||||
var grid = new MacroCell[Width, Height];
|
||||
// Fill with default
|
||||
for (int y = 0; y < Height; y++)
|
||||
for (int x = 0; x < Width; x++)
|
||||
grid[x, y] = DefaultCell;
|
||||
// Paint regions in order (later regions win)
|
||||
foreach (var r in Regions)
|
||||
{
|
||||
var cell = r.ToCell();
|
||||
int x1 = Math.Min(r.X + r.W, Width);
|
||||
int y1 = Math.Min(r.Y + r.H, Height);
|
||||
for (int y = Math.Max(0, r.Y); y < y1; y++)
|
||||
for (int x = Math.Max(0, r.X); x < x1; x++)
|
||||
grid[x, y] = cell;
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A rectangular block in the macro template, painted over the default.</summary>
|
||||
public sealed class MacroRegion
|
||||
{
|
||||
[JsonPropertyName("x")] public int X { get; set; }
|
||||
[JsonPropertyName("y")] public int Y { get; set; }
|
||||
[JsonPropertyName("w")] public int W { get; set; }
|
||||
[JsonPropertyName("h")] public int H { get; set; }
|
||||
/// <summary>Human-readable annotation in the JSON file — ignored at runtime.</summary>
|
||||
[JsonPropertyName("comment")] public string? Comment { get; set; }
|
||||
|
||||
[JsonPropertyName("biome_type")] public string BiomeType { get; set; } = "temperate_grassland";
|
||||
[JsonPropertyName("clade_affinities")] public string[] CladeAffinities { get; set; } = Array.Empty<string>();
|
||||
[JsonPropertyName("development")] public string Development { get; set; } = "agricultural";
|
||||
[JsonPropertyName("covenant")] public string Covenant { get; set; } = "moderate";
|
||||
[JsonPropertyName("elevation_floor")] public float ElevationFloor { get; set; } = 0f;
|
||||
[JsonPropertyName("elevation_ceiling")]public float ElevationCeiling { get; set; } = 1f;
|
||||
[JsonPropertyName("moisture_floor")] public float MoistureFloor { get; set; } = 0f;
|
||||
[JsonPropertyName("moisture_ceiling")] public float MoistureCeiling { get; set; } = 1f;
|
||||
[JsonPropertyName("temp_modifier")] public float TempModifier { get; set; } = 0f;
|
||||
|
||||
public MacroCell ToCell() => new()
|
||||
{
|
||||
BiomeType = BiomeType,
|
||||
CladeAffinities = CladeAffinities,
|
||||
Development = Development,
|
||||
Covenant = Covenant,
|
||||
ElevationFloor = ElevationFloor,
|
||||
ElevationCeiling = ElevationCeiling,
|
||||
MoistureFloor = MoistureFloor,
|
||||
MoistureCeiling = MoistureCeiling,
|
||||
TempModifier = TempModifier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable NPC stat-block template loaded from npc_templates.json.
|
||||
/// Phase 5 instantiates one per <see cref="Tactical.SpawnKind"/> per
|
||||
/// chunk, with the actual template id chosen via the per-zone lookup
|
||||
/// table on <see cref="NpcTemplateContent.SpawnKindToTemplateByZone"/>.
|
||||
/// </summary>
|
||||
public sealed record NpcTemplateDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Body size category, snake_case (small / medium / medium_large / large).</summary>
|
||||
[JsonPropertyName("size")]
|
||||
public string Size { get; init; } = "medium";
|
||||
|
||||
/// <summary>STR/DEX/CON/INT/WIS/CHA absolute values (10 = average).</summary>
|
||||
[JsonPropertyName("ability_scores")]
|
||||
public Dictionary<string, int> AbilityScores { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("hp")]
|
||||
public int Hp { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("ac")]
|
||||
public int Ac { get; init; } = 10;
|
||||
|
||||
[JsonPropertyName("speed_ft")]
|
||||
public int SpeedFt { get; init; } = 30;
|
||||
|
||||
[JsonPropertyName("attacks")]
|
||||
public NpcAttack[] Attacks { get; init; } = Array.Empty<NpcAttack>();
|
||||
|
||||
/// <summary>Behavior id ("brigand", "wild_animal", "poi_guard"). Maps to <c>INpcBehavior</c> in Phase 5 M5.</summary>
|
||||
[JsonPropertyName("behavior")]
|
||||
public string Behavior { get; init; } = "brigand";
|
||||
|
||||
/// <summary>Starts as Hostile / Neutral / Friendly. Phase 5 reads this on instantiation.</summary>
|
||||
[JsonPropertyName("default_allegiance")]
|
||||
public string DefaultAllegiance { get; init; } = "hostile";
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M5 — faction id this NPC owes allegiance to (matches
|
||||
/// FactionDef.Id). Empty for unaligned templates (wild animals,
|
||||
/// brigands). Drives M5 patrol-aggression: a non-hostile NPC with a
|
||||
/// faction flips to Hostile when the player's local standing with
|
||||
/// that faction crosses the HOSTILE threshold.
|
||||
/// </summary>
|
||||
[JsonPropertyName("faction")]
|
||||
public string Faction { get; init; } = "";
|
||||
|
||||
/// <summary>Loot table id (Phase 5 ships ~5; lookup deferred to M6).</summary>
|
||||
[JsonPropertyName("loot_table")]
|
||||
public string LootTable { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("xp_award")]
|
||||
public int XpAward { get; init; } = 0;
|
||||
}
|
||||
|
||||
public sealed record NpcAttack
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("to_hit")]
|
||||
public int ToHit { get; init; } = 0;
|
||||
|
||||
/// <summary>Damage dice expression (e.g. "1d6+2", "2d8").</summary>
|
||||
[JsonPropertyName("damage")]
|
||||
public string Damage { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("damage_type")]
|
||||
public string DamageType { get; init; } = "bludgeoning";
|
||||
|
||||
[JsonPropertyName("reach_tiles")]
|
||||
public int ReachTiles { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("range_short_tiles")]
|
||||
public int RangeShortTiles { get; init; } = 0;
|
||||
|
||||
[JsonPropertyName("range_long_tiles")]
|
||||
public int RangeLongTiles { get; init; } = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Top-level wrapper for npc_templates.json: the template list plus the
|
||||
/// per-spawnkind, per-zone template-id lookup table.
|
||||
/// </summary>
|
||||
public sealed record NpcTemplateContent
|
||||
{
|
||||
[JsonPropertyName("templates")]
|
||||
public NpcTemplateDef[] Templates { get; init; } = Array.Empty<NpcTemplateDef>();
|
||||
|
||||
/// <summary>
|
||||
/// SpawnKind name (e.g. "Brigand") → array of template ids indexed by
|
||||
/// DangerZone (0..4). Length should equal <c>C.DANGER_ZONE_MAX + 1</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("spawn_kind_to_template_by_zone")]
|
||||
public Dictionary<string, string[]> SpawnKindToTemplateByZone { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M2 — per-dungeon-type override. Resolves a
|
||||
/// <c>(PoiType, SpawnKind)</c> pair to a single template id, used by
|
||||
/// <see cref="Theriapolis.Core.Dungeons.DungeonPopulator"/> when filling
|
||||
/// in-room encounter slots. Per Phase 7 plan §10 open-decision #6:
|
||||
/// DungeonType supersedes DangerZone entirely once the player is inside
|
||||
/// a dungeon, so this map's value is a single template id (no zone
|
||||
/// indexing). Outer key matches the <see cref="World.PoiType"/> enum
|
||||
/// name (e.g. <c>"ImperiumRuin"</c>); inner key is the spawn-kind name
|
||||
/// (e.g. <c>"PoiGuard"</c> / <c>"WildAnimal"</c> / <c>"Brigand"</c> /
|
||||
/// <c>"Boss"</c>). Missing keys fall back to
|
||||
/// <see cref="SpawnKindToTemplateByZone"/> at <c>DangerZone</c>=2 mid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("spawn_kind_to_template_by_dungeon_type")]
|
||||
public Dictionary<string, Dictionary<string, string>> SpawnKindToTemplateByDungeonType { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M4 — JSON-loaded quest definition. A quest is a directed graph
|
||||
/// of steps; the engine starts at <see cref="EntryStep"/>, evaluates each
|
||||
/// active step's <see cref="QuestStepDef.TriggerConditions"/> per tick,
|
||||
/// and runs <see cref="QuestStepDef.OnEnter"/> + <see cref="QuestStepDef.Outcomes"/>
|
||||
/// when the step fires.
|
||||
///
|
||||
/// Author convention: one tree per file in
|
||||
/// <c>Content/Data/quests/*.json</c>. <see cref="Id"/> matches the
|
||||
/// filename. Step ids are strings local to the tree.
|
||||
/// </summary>
|
||||
public sealed record QuestDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
/// <summary>True when the quest doesn't appear in the journal until first activation (Maw discovery, etc.).</summary>
|
||||
[JsonPropertyName("hidden")]
|
||||
public bool Hidden { get; init; } = false;
|
||||
|
||||
/// <summary>Step id the engine activates on quest start.</summary>
|
||||
[JsonPropertyName("entry_step")]
|
||||
public string EntryStep { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("steps")]
|
||||
public QuestStepDef[] Steps { get; init; } = System.Array.Empty<QuestStepDef>();
|
||||
|
||||
/// <summary>
|
||||
/// Optional: triggers that auto-start this quest. The engine checks
|
||||
/// these against world state on every tick; when any fires, the quest
|
||||
/// activates at <see cref="EntryStep"/>. Empty = manual-start (e.g.
|
||||
/// dialogue's <c>start_quest</c> effect).
|
||||
/// </summary>
|
||||
[JsonPropertyName("auto_start_when")]
|
||||
public QuestConditionDef[] AutoStartWhen { get; init; } = System.Array.Empty<QuestConditionDef>();
|
||||
}
|
||||
|
||||
public sealed record QuestStepDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
/// <summary>Optional waypoint hint — anchor or role tag the player should head toward.</summary>
|
||||
[JsonPropertyName("waypoint")]
|
||||
public string Waypoint { get; init; } = "";
|
||||
|
||||
/// <summary>Conditions that fire this step's onEnter + outcomes when ALL true.</summary>
|
||||
[JsonPropertyName("trigger_conditions")]
|
||||
public QuestConditionDef[] TriggerConditions { get; init; } = System.Array.Empty<QuestConditionDef>();
|
||||
|
||||
/// <summary>Effects applied once when the step fires.</summary>
|
||||
[JsonPropertyName("on_enter")]
|
||||
public QuestEffectDef[] OnEnter { get; init; } = System.Array.Empty<QuestEffectDef>();
|
||||
|
||||
/// <summary>Step ids this step transitions into (any one is selected via outcome conditions).</summary>
|
||||
[JsonPropertyName("outcomes")]
|
||||
public QuestOutcomeDef[] Outcomes { get; init; } = System.Array.Empty<QuestOutcomeDef>();
|
||||
|
||||
/// <summary>True if reaching this step completes the quest (success).</summary>
|
||||
[JsonPropertyName("completes_quest")]
|
||||
public bool CompletesQuest { get; init; } = false;
|
||||
|
||||
/// <summary>True if reaching this step fails the quest.</summary>
|
||||
[JsonPropertyName("fails_quest")]
|
||||
public bool FailsQuest { get; init; } = false;
|
||||
}
|
||||
|
||||
public sealed record QuestOutcomeDef
|
||||
{
|
||||
/// <summary>Step id to transition to. <c>"<end>"</c> closes the quest.</summary>
|
||||
[JsonPropertyName("next")]
|
||||
public string Next { get; init; } = "";
|
||||
|
||||
/// <summary>Conditions for THIS outcome to be selected. Empty = always.</summary>
|
||||
[JsonPropertyName("when")]
|
||||
public QuestConditionDef[] When { get; init; } = System.Array.Empty<QuestConditionDef>();
|
||||
|
||||
[JsonPropertyName("effects")]
|
||||
public QuestEffectDef[] Effects { get; init; } = System.Array.Empty<QuestEffectDef>();
|
||||
}
|
||||
|
||||
/// <summary>Trigger / outcome predicate.</summary>
|
||||
public sealed record QuestConditionDef
|
||||
{
|
||||
/// <summary>
|
||||
/// One of: "flag_set", "flag_clear", "flag_at_least", "enter_anchor",
|
||||
/// "enter_role_proximity", "npc_dead", "npc_alive", "time_elapsed_seconds",
|
||||
/// "rep_at_least", "rep_below", "has_item", "not_has_item",
|
||||
/// "quest_complete", "quest_active", "dialogue_choice".
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("flag")]
|
||||
public string Flag { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("anchor")]
|
||||
public string Anchor { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("npc")]
|
||||
public string Npc { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("faction")]
|
||||
public string Faction { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("quest")]
|
||||
public string Quest { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public int Value { get; init; }
|
||||
|
||||
[JsonPropertyName("seconds")]
|
||||
public long Seconds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Quest-step side effect.</summary>
|
||||
public sealed record QuestEffectDef
|
||||
{
|
||||
/// <summary>
|
||||
/// One of: "set_flag", "clear_flag", "give_item", "take_item",
|
||||
/// "give_xp", "rep_event", "spawn_npc", "despawn_npc",
|
||||
/// "start_quest", "end_quest", "fail_quest".
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("flag")]
|
||||
public string Flag { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public int Value { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("qty")]
|
||||
public int Qty { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("xp")]
|
||||
public int Xp { get; init; }
|
||||
|
||||
[JsonPropertyName("event")]
|
||||
public DialogueRepEventDef? Event { get; init; }
|
||||
|
||||
[JsonPropertyName("quest")]
|
||||
public string Quest { get; init; } = "";
|
||||
|
||||
/// <summary>For spawn_npc: resident template id (named takes precedence).</summary>
|
||||
[JsonPropertyName("template")]
|
||||
public string Template { get; init; } = "";
|
||||
|
||||
/// <summary>For spawn_npc/despawn_npc: which named role tag is being mutated.</summary>
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 — definition of a friendly/neutral resident NPC inhabiting a
|
||||
/// settlement. Two flavours, both loaded from <c>resident_templates.json</c>:
|
||||
///
|
||||
/// 1. **Generic** (<c>Named == false</c>) — matches by <see cref="RoleTag"/>
|
||||
/// prefix. <see cref="ResidentInstantiator"/> picks the highest-weight
|
||||
/// generic whose RoleTag equals the building-role tag (e.g. "innkeeper",
|
||||
/// "shopkeeper", "guard").
|
||||
///
|
||||
/// 2. **Named** (<c>Named == true</c>) — matches by exact <see cref="RoleTag"/>
|
||||
/// (e.g. "millhaven.innkeeper"). Used when a settlement preset's
|
||||
/// <c>role_overrides</c> qualifies a building role with a specific
|
||||
/// anchor-prefixed tag, locking that NPC to a hand-authored species,
|
||||
/// name, and bias profile.
|
||||
///
|
||||
/// Combat stats are minimal in M1 — residents are non-combatants by
|
||||
/// default. They have a token <see cref="Hp"/>/<see cref="Ac"/> in case the
|
||||
/// player attacks them; engagement promotes them to a derived combatant
|
||||
/// using the existing <see cref="NpcTemplateDef"/>-style stat block.
|
||||
/// </summary>
|
||||
public sealed record ResidentTemplateDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The role tag this template matches. Generic templates use bare
|
||||
/// occupations ("innkeeper"); named templates use anchor-prefixed
|
||||
/// "settlement.role" ids ("millhaven.innkeeper").
|
||||
/// </summary>
|
||||
[JsonPropertyName("role_tag")]
|
||||
public string RoleTag { get; init; } = "";
|
||||
|
||||
/// <summary>True when this template is hand-authored for a specific named NPC. Always wins over generic when role_tag matches.</summary>
|
||||
[JsonPropertyName("named")]
|
||||
public bool Named { get; init; } = false;
|
||||
|
||||
/// <summary>Display name shown in dialogue + tooltip. Empty for generics → resolved from <see cref="RoleTag"/>.</summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Clade id (e.g. "canidae"). Required for named templates; generics may leave empty to roll from settlement biome.</summary>
|
||||
[JsonPropertyName("clade")]
|
||||
public string Clade { get; init; } = "";
|
||||
|
||||
/// <summary>Species id (e.g. "wolf"). Required for named; generics roll from clade.</summary>
|
||||
[JsonPropertyName("species")]
|
||||
public string Species { get; init; } = "";
|
||||
|
||||
/// <summary>Bias profile id this NPC carries (matches BiasProfileDef.Id).</summary>
|
||||
[JsonPropertyName("bias_profile")]
|
||||
public string BiasProfile { get; init; } = "URBAN_PROGRESSIVE";
|
||||
|
||||
/// <summary>Faction affiliation id (matches FactionDef.Id), or empty for unaffiliated.</summary>
|
||||
[JsonPropertyName("faction")]
|
||||
public string Faction { get; init; } = "";
|
||||
|
||||
/// <summary>Dialogue tree id (matches dialogues/*.json id). Empty → fall back to a generic-by-role placeholder.</summary>
|
||||
[JsonPropertyName("dialogue")]
|
||||
public string Dialogue { get; init; } = "";
|
||||
|
||||
/// <summary>"friendly" or "neutral". Defaults to friendly. Hostile residents go through the npc_templates path instead.</summary>
|
||||
[JsonPropertyName("default_allegiance")]
|
||||
public string DefaultAllegiance { get; init; } = "friendly";
|
||||
|
||||
/// <summary>Stat block for combat fallback. Defaults are commoner-ish (HP 8, AC 10).</summary>
|
||||
[JsonPropertyName("hp")]
|
||||
public int Hp { get; init; } = 8;
|
||||
|
||||
[JsonPropertyName("ac")]
|
||||
public int Ac { get; init; } = 10;
|
||||
|
||||
[JsonPropertyName("ability_scores")]
|
||||
public Dictionary<string, int> AbilityScores { get; init; } = new();
|
||||
|
||||
/// <summary>Selection weight when multiple generic templates match the same role tag.</summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public float Weight { get; init; } = 1f;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M0 — a single hand-authored dungeon room. Loaded from
|
||||
/// <c>Content/Data/room_templates/<type>/*.json</c>.
|
||||
///
|
||||
/// A template describes:
|
||||
/// - The room's footprint in tactical tiles (perimeter walls included).
|
||||
/// - The 2D ASCII <see cref="Grid"/>: one char per tactical tile.
|
||||
/// Legend (per Phase 7 plan §5.1):
|
||||
/// <c>#</c> wall, <c>.</c> floor, <c>,</c> rubble, <c>D</c> door slot,
|
||||
/// <c>@</c> encounter slot, <c>C</c> container slot, <c>T</c> trap slot,
|
||||
/// <c>P</c> pillar, <c>B</c> brazier, <c>M</c> mosaic (narrative),
|
||||
/// <c>S</c> stairs (entry/exit only).
|
||||
/// - Door positions on the perimeter (one entry per <c>D</c> in the grid).
|
||||
/// - Encounter / container / trap slot positions (which the dungeon
|
||||
/// populator fills with NPCs and loot).
|
||||
/// - Optional narrative text surfaced by Scent Literacy / room-clear coda.
|
||||
/// - The clade that <see cref="BuiltBy"/> the room — drives the
|
||||
/// clade-responsive movement multiplier (Phase 7 plan §5.4).
|
||||
///
|
||||
/// Templates are designer-friendly to author: edit ASCII art + a couple
|
||||
/// of metadata blocks. <see cref="ContentLoader.LoadRoomTemplates"/>
|
||||
/// validates grid dimensions vs declared footprint, perimeter walls,
|
||||
/// and that every <c>D</c>/<c>@</c>/<c>C</c>/<c>T</c> in the grid has
|
||||
/// a matching slot record.
|
||||
/// </summary>
|
||||
public sealed record RoomTemplateDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Dungeon type the template belongs to: <c>imperium</c>, <c>mine</c>,
|
||||
/// <c>cult</c>, <c>cave</c>, <c>overgrown</c>. Layout matchers filter
|
||||
/// templates by type when assembling a dungeon.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Clade that built or originally inhabited the room. Drives Phase 7
|
||||
/// clade-responsive movement (a Large PC in a Mustelid tunnel takes 2×
|
||||
/// movement points). Allowed: <c>canid</c>, <c>felid</c>, <c>mustelid</c>,
|
||||
/// <c>ursid</c>, <c>cervid</c>, <c>bovid</c>, <c>leporid</c>,
|
||||
/// <c>imperium</c>, <c>none</c>. Phase-7 Imperium templates use
|
||||
/// <c>imperium</c>; templates that would be at home in any dungeon use
|
||||
/// <c>none</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("built_by")]
|
||||
public string BuiltBy { get; init; } = "none";
|
||||
|
||||
/// <summary>
|
||||
/// Size class — <c>small</c>, <c>medium</c>, <c>large</c>. Used by
|
||||
/// the layout matcher to pick room mixes appropriate to the dungeon's
|
||||
/// size band (small dungeons prefer small rooms, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("size_class")]
|
||||
public string SizeClass { get; init; } = "medium";
|
||||
|
||||
/// <summary>
|
||||
/// Roles this template is eligible for: <c>entry</c>, <c>transit</c>,
|
||||
/// <c>narrative</c>, <c>loot</c>, <c>boss</c>, <c>dead-end</c>. A
|
||||
/// template can be eligible for multiple roles (a "pillar room" can
|
||||
/// serve as transit OR as a loot stash).
|
||||
/// </summary>
|
||||
[JsonPropertyName("roles_eligible")]
|
||||
public string[] RolesEligible { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Footprint width in tactical tiles. Includes perimeter walls. Must equal Grid[*].Length.</summary>
|
||||
[JsonPropertyName("footprint_w_tiles")]
|
||||
public int FootprintWTiles { get; init; } = 1;
|
||||
|
||||
/// <summary>Footprint height in tactical tiles. Includes perimeter walls. Must equal Grid.Length.</summary>
|
||||
[JsonPropertyName("footprint_h_tiles")]
|
||||
public int FootprintHTiles { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 2D ASCII art: one entry per row, one char per tactical tile.
|
||||
/// Validated for perimeter wall completeness and slot-coordinate
|
||||
/// matches at content-load time.
|
||||
/// </summary>
|
||||
[JsonPropertyName("grid")]
|
||||
public string[] Grid { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Door positions in template-local coords (matches <c>D</c> chars in <see cref="Grid"/>).</summary>
|
||||
[JsonPropertyName("doors")]
|
||||
public RoomDoor[] Doors { get; init; } = Array.Empty<RoomDoor>();
|
||||
|
||||
/// <summary>Encounter slot positions (matches <c>@</c> chars).</summary>
|
||||
[JsonPropertyName("encounter_slots")]
|
||||
public RoomEncounterSlot[] EncounterSlots { get; init; } = Array.Empty<RoomEncounterSlot>();
|
||||
|
||||
/// <summary>Container slot positions (matches <c>C</c> chars).</summary>
|
||||
[JsonPropertyName("container_slots")]
|
||||
public RoomContainerSlot[] ContainerSlots { get; init; } = Array.Empty<RoomContainerSlot>();
|
||||
|
||||
/// <summary>Trap slot positions (matches <c>T</c> chars). Phase 7 ships only tripwire traps.</summary>
|
||||
[JsonPropertyName("trap_slots")]
|
||||
public RoomTrapSlot[] TrapSlots { get; init; } = Array.Empty<RoomTrapSlot>();
|
||||
|
||||
/// <summary>Decoration placements for non-slot decos (P pillar, B brazier, M mosaic).</summary>
|
||||
[JsonPropertyName("decos")]
|
||||
public RoomDecoPlacement[] Decos { get; init; } = Array.Empty<RoomDecoPlacement>();
|
||||
|
||||
/// <summary>
|
||||
/// Environmental-story prose surfaced by Scent Literacy (Phase 6.5 M1)
|
||||
/// in the InteractionScreen scent-overlay panel and by the dungeon-
|
||||
/// clear coda. Null/empty for non-narrative templates.
|
||||
/// </summary>
|
||||
[JsonPropertyName("narrative_text")]
|
||||
public string? NarrativeText { get; init; } = null;
|
||||
|
||||
/// <summary>Selection weight in layout assembly. Default 1.0.</summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public float Weight { get; init; } = 1f;
|
||||
}
|
||||
|
||||
public sealed record RoomDoor
|
||||
{
|
||||
[JsonPropertyName("x")]
|
||||
public int X { get; init; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public int Y { get; init; }
|
||||
|
||||
/// <summary>Compass facing: "N" / "E" / "S" / "W". Door always sits on a perimeter cell.</summary>
|
||||
[JsonPropertyName("facing")]
|
||||
public string Facing { get; init; } = "S";
|
||||
|
||||
/// <summary>
|
||||
/// Optional lock difficulty for this door. Empty = unlocked. Allowed:
|
||||
/// <c>trivial</c>, <c>easy</c>, <c>medium</c>, <c>hard</c> — mapped to
|
||||
/// <c>LOCK_DC_*</c> constants in code.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lock")]
|
||||
public string Lock { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record RoomEncounterSlot
|
||||
{
|
||||
[JsonPropertyName("x")]
|
||||
public int X { get; init; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public int Y { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Spawn kind: <c>PoiGuard</c> / <c>WildAnimal</c> / <c>Brigand</c> /
|
||||
/// <c>Boss</c>. Resolved against <c>npc_templates.json</c>'s
|
||||
/// per-dungeon-type spawn-kind override map at populate time.
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = "PoiGuard";
|
||||
|
||||
/// <summary>Likelihood the slot fires when a layout calls for variability. 1.0 = always.</summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public float Weight { get; init; } = 1f;
|
||||
}
|
||||
|
||||
public sealed record RoomContainerSlot
|
||||
{
|
||||
[JsonPropertyName("x")]
|
||||
public int X { get; init; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public int Y { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Loot-table band: <c>t1</c> / <c>t2</c> / <c>t3</c>. The dungeon's
|
||||
/// layout maps a band to a real loot-table id at populate time
|
||||
/// (<see cref="DungeonLayoutDef.LootTablePerBand"/>).
|
||||
/// </summary>
|
||||
[JsonPropertyName("loot_table_band")]
|
||||
public string LootTableBand { get; init; } = "t1";
|
||||
|
||||
/// <summary>True when the container is locked (key required, or STR/DEX check).</summary>
|
||||
[JsonPropertyName("locked")]
|
||||
public bool Locked { get; init; } = false;
|
||||
|
||||
/// <summary>Optional lock difficulty if <see cref="Locked"/>: trivial/easy/medium/hard.</summary>
|
||||
[JsonPropertyName("lock")]
|
||||
public string Lock { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record RoomTrapSlot
|
||||
{
|
||||
[JsonPropertyName("x")]
|
||||
public int X { get; init; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public int Y { get; init; }
|
||||
|
||||
/// <summary>Trap kind. Phase 7 ships only <c>tripwire</c>.</summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = "tripwire";
|
||||
|
||||
/// <summary>Disarm DC tier: <c>trivial</c>, <c>easy</c>, <c>medium</c>.</summary>
|
||||
[JsonPropertyName("disarm_dc")]
|
||||
public string DisarmDc { get; init; } = "easy";
|
||||
}
|
||||
|
||||
public sealed record RoomDecoPlacement
|
||||
{
|
||||
[JsonPropertyName("x")]
|
||||
public int X { get; init; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public int Y { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deco kind name. Allowed: <c>pillar</c>, <c>brazier</c>, <c>mosaic</c>,
|
||||
/// <c>imperium_statue</c>. Trap / container / door / stairs decos are
|
||||
/// declared via their respective slot collections, not here.
|
||||
/// </summary>
|
||||
[JsonPropertyName("deco")]
|
||||
public string Deco { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M0 — bundle of all settlement-stamp content needed to drive
|
||||
/// <see cref="World.Settlements.SettlementStamper"/>. Held by
|
||||
/// <see cref="ContentResolver"/>; passed into <see cref="Tactical.TacticalChunkGen"/>
|
||||
/// as an optional argument so headless tools that don't need full content
|
||||
/// (e.g. worldgen-dump) can run without it.
|
||||
/// </summary>
|
||||
public sealed class SettlementContent
|
||||
{
|
||||
public IReadOnlyDictionary<string, BuildingTemplateDef> Buildings { get; }
|
||||
public IReadOnlyDictionary<string, SettlementLayoutDef> PresetByAnchor { get; }
|
||||
|
||||
/// <summary>Tier 1..5 → procedural layout (or null if no procedural layout for that tier).</summary>
|
||||
public IReadOnlyDictionary<int, SettlementLayoutDef> ProceduralByTier { get; }
|
||||
|
||||
public SettlementContent(
|
||||
IReadOnlyDictionary<string, BuildingTemplateDef> buildings,
|
||||
IReadOnlyDictionary<string, SettlementLayoutDef> presetByAnchor,
|
||||
IReadOnlyDictionary<int, SettlementLayoutDef> proceduralByTier)
|
||||
{
|
||||
Buildings = buildings;
|
||||
PresetByAnchor = presetByAnchor;
|
||||
ProceduralByTier = proceduralByTier;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up the layout to use for a given settlement, preferring the
|
||||
/// hand-authored preset for its anchor (if any) and falling back to the
|
||||
/// procedural layout for its tier.
|
||||
/// </summary>
|
||||
public SettlementLayoutDef? ResolveFor(World.Settlement settlement)
|
||||
{
|
||||
if (settlement.Anchor is { } anchor &&
|
||||
PresetByAnchor.TryGetValue(anchor.ToString(), out var preset))
|
||||
return preset;
|
||||
if (ProceduralByTier.TryGetValue(settlement.Tier, out var proc))
|
||||
return proc;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M0 — describes how to lay buildings inside a settlement footprint.
|
||||
///
|
||||
/// Two flavours:
|
||||
/// 1. **Hand-authored preset** — `kind == "preset"`. The <see cref="Buildings"/>
|
||||
/// array specifies each building by template id and offset from the
|
||||
/// settlement centre. Used for narrative anchors (Millhaven, Thornfield).
|
||||
/// 2. **Procedural rule-based** — `kind == "procedural"`. The <see cref="Roles"/>
|
||||
/// array specifies a mix of category weights ("inn" 0.1, "shop" 0.3,
|
||||
/// "house" 0.6) and a target building count; <see cref="World.Settlements.SettlementStamper"/>
|
||||
/// rolls templates from the matching categories until the target count is
|
||||
/// met or no more building slots fit inside the plaza radius.
|
||||
///
|
||||
/// Bound to a settlement either by anchor name (preset) or by tier
|
||||
/// (procedural fallback for any non-anchor settlement of that tier).
|
||||
/// </summary>
|
||||
public sealed record SettlementLayoutDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>"preset" or "procedural".</summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = "procedural";
|
||||
|
||||
/// <summary>For preset layouts: matches Settlement.Anchor.ToString() (e.g. "Millhaven").</summary>
|
||||
[JsonPropertyName("anchor")]
|
||||
public string Anchor { get; init; } = "";
|
||||
|
||||
/// <summary>For procedural layouts: matches Settlement.Tier (1–5).</summary>
|
||||
[JsonPropertyName("tier")]
|
||||
public int Tier { get; init; } = 0;
|
||||
|
||||
// ── Preset payload ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Building placements for preset layouts. Ignored when kind == "procedural".</summary>
|
||||
[JsonPropertyName("buildings")]
|
||||
public SettlementBuildingPlacement[] Buildings { get; init; } = Array.Empty<SettlementBuildingPlacement>();
|
||||
|
||||
// ── Procedural payload ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Category mix for procedural layouts. Ignored when kind == "preset".</summary>
|
||||
[JsonPropertyName("category_weights")]
|
||||
public Dictionary<string, float> CategoryWeights { get; init; } = new();
|
||||
|
||||
/// <summary>Target building count for procedural layouts.</summary>
|
||||
[JsonPropertyName("target_building_count")]
|
||||
public int TargetBuildingCount { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Plaza radius in tactical tiles to search for building slots (procedural).
|
||||
/// If 0, the stamper picks one based on tier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("plaza_radius_tiles")]
|
||||
public int PlazaRadiusTiles { get; init; } = 0;
|
||||
}
|
||||
|
||||
public sealed record SettlementBuildingPlacement
|
||||
{
|
||||
/// <summary>BuildingTemplateDef.Id to stamp.</summary>
|
||||
[JsonPropertyName("template")]
|
||||
public string Template { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Offset from settlement centre in tactical tiles. (0,0) places the
|
||||
/// building's centre on the settlement centre tile.
|
||||
/// </summary>
|
||||
[JsonPropertyName("offset")]
|
||||
public int[] Offset { get; init; } = new[] { 0, 0 };
|
||||
|
||||
/// <summary>
|
||||
/// Optional rotation: 0 / 90 / 180 / 270. Phase 6 M0 ignores; reserved
|
||||
/// so layouts don't have to be re-authored when rotation lands.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rotation_deg")]
|
||||
public int RotationDeg { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Override the role tag for one or more roles in this building. E.g.
|
||||
/// the innkeeper template has role "innkeeper"; this preset assigns
|
||||
/// it the named role "millhaven.innkeeper" so quest scripts can
|
||||
/// reference the specific NPC.
|
||||
/// </summary>
|
||||
[JsonPropertyName("role_overrides")]
|
||||
public Dictionary<string, string> RoleOverrides { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable species (subrace-equivalent) record loaded from species.json.
|
||||
/// Refines the parent <see cref="CladeDef"/>: adds a body size, additional
|
||||
/// ability mods, species-specific traits, and species-specific detriments.
|
||||
/// </summary>
|
||||
public sealed record SpeciesDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("clade_id")]
|
||||
public string CladeId { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Body size category, snake_case (small / medium / medium_large / large).</summary>
|
||||
[JsonPropertyName("size")]
|
||||
public string Size { get; init; } = "medium";
|
||||
|
||||
/// <summary>Additional ability mods on top of the clade's mods.</summary>
|
||||
[JsonPropertyName("ability_mods")]
|
||||
public Dictionary<string, int> AbilityMods { get; init; } = new();
|
||||
|
||||
/// <summary>Base movement speed in feet per turn (5 ft. = 1 tactical tile per d20 standard).</summary>
|
||||
[JsonPropertyName("base_speed_ft")]
|
||||
public int BaseSpeedFt { get; init; } = 30;
|
||||
|
||||
[JsonPropertyName("traits")]
|
||||
public TraitDef[] Traits { get; init; } = Array.Empty<TraitDef>();
|
||||
|
||||
[JsonPropertyName("detriments")]
|
||||
public TraitDef[] Detriments { get; init; } = Array.Empty<TraitDef>();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable subclass definition loaded from subclasses.json. Phase 5
|
||||
/// stores these but does not apply mechanics — subclass selection is the
|
||||
/// level-3 flow that ships with Phase 5.5 / 6 leveling. Loaded so
|
||||
/// <c>ContentValidate</c> can verify referential integrity from
|
||||
/// <see cref="ClassDef.SubclassIds"/>.
|
||||
/// </summary>
|
||||
public sealed record SubclassDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("class_id")]
|
||||
public string ClassId { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("flavor")]
|
||||
public string Flavor { get; init; } = "";
|
||||
|
||||
/// <summary>Level → feature ids unlocked. Same shape as the class table.</summary>
|
||||
[JsonPropertyName("level_features")]
|
||||
public SubclassLevelEntry[] LevelFeatures { get; init; } = Array.Empty<SubclassLevelEntry>();
|
||||
|
||||
/// <summary>Subclass-specific feature descriptions, keyed by feature id.</summary>
|
||||
[JsonPropertyName("feature_definitions")]
|
||||
public Dictionary<string, ClassFeatureDef> FeatureDefinitions { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record SubclassLevelEntry
|
||||
{
|
||||
[JsonPropertyName("level")]
|
||||
public int Level { get; init; } = 3;
|
||||
|
||||
[JsonPropertyName("features")]
|
||||
public string[] Features { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Trait or detriment entry shared by clades, species, and class features.
|
||||
/// Phase 5 mostly stores these as descriptive text — only a handful have
|
||||
/// real runtime mechanics (level-1 combat-touching features). The rest
|
||||
/// surface as flavor in tooltips and the character sheet UI.
|
||||
/// </summary>
|
||||
public sealed record TraitDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M2 — clade-responsive dungeon movement cost. Per <c>procgen.md</c>
|
||||
/// Layer 5 final paragraph and Phase 7 plan §5.4: a Large PC squeezing
|
||||
/// through a Mustelid tunnel takes 2× movement points per tile; a Small
|
||||
/// PC in an Ursid hall is exposed (×1.5); etc.
|
||||
///
|
||||
/// Cost multiplier applies to tactical-tile movement budget per turn —
|
||||
/// combat reach + LOS unchanged; <em>only</em> movement budget. The
|
||||
/// caller (<see cref="Rules.Combat.TacticalMovementRules"/> or equivalent)
|
||||
/// looks up the room the actor is currently in and consults
|
||||
/// <see cref="GetCostMultiplier"/>.
|
||||
///
|
||||
/// Hybrid PCs use their <em>dominant lineage</em>'s clade-implied size
|
||||
/// for the lookup — matches the Phase 6.5 hybrid passing / presenting-clade
|
||||
/// contract. A Wolf-Folk × Hare-Folk hybrid with <c>DominantParent: Sire</c>
|
||||
/// reads as Wolf-Folk (MediumLarge); with <c>DominantParent: Dam</c> reads
|
||||
/// as Hare-Folk (Medium). Outside dungeons the multiplier is always 1.0.
|
||||
///
|
||||
/// Reference table (Phase 7 plan §5.4):
|
||||
/// Player size | Built by Mustelid | Ursid | Cervid | Bovid | Imperium/None
|
||||
/// Small | 1.0 | 1.5 | 1.0 | 1.2 | 1.0
|
||||
/// Medium | 1.2 | 1.0 | 1.0 | 1.0 | 1.0
|
||||
/// MediumLarge | 1.5 | 1.0 | 1.0 | 1.0 | 1.0
|
||||
/// Large | 2.0 | 1.0 | 1.2 | 1.0 | 1.0
|
||||
/// </summary>
|
||||
public static class ClademorphicMovement
|
||||
{
|
||||
/// <summary>
|
||||
/// Multiplier on movement-cost-per-tile for a player of the given size
|
||||
/// in a room built by the given clade. Returns 1.0 when no mismatch
|
||||
/// applies. Unknown <paramref name="builtBy"/> values default to 1.0
|
||||
/// (no penalty).
|
||||
/// </summary>
|
||||
public static float GetCostMultiplier(SizeCategory playerSize, string builtBy)
|
||||
{
|
||||
if (string.IsNullOrEmpty(builtBy)) return 1.0f;
|
||||
// Normalise — JSON ships lowercase tags.
|
||||
return builtBy.ToLowerInvariant() switch
|
||||
{
|
||||
"mustelid" => playerSize switch
|
||||
{
|
||||
SizeCategory.Small => 1.0f,
|
||||
SizeCategory.Medium => C.MOVE_COST_MISMATCH_LIGHT, // 1.2 — slight squeeze
|
||||
SizeCategory.MediumLarge => C.MOVE_COST_MISMATCH_MED, // 1.5
|
||||
SizeCategory.Large => C.MOVE_COST_MISMATCH_HEAVY, // 2.0 — squeezing
|
||||
_ => 1.0f,
|
||||
},
|
||||
"ursid" => playerSize switch
|
||||
{
|
||||
SizeCategory.Small => C.MOVE_COST_MISMATCH_MED, // exposed in cavernous halls
|
||||
_ => 1.0f,
|
||||
},
|
||||
"cervid" => playerSize switch
|
||||
{
|
||||
SizeCategory.Large => C.MOVE_COST_MISMATCH_LIGHT, // antler clearance
|
||||
_ => 1.0f,
|
||||
},
|
||||
"bovid" => playerSize switch
|
||||
{
|
||||
SizeCategory.Small => C.MOVE_COST_MISMATCH_LIGHT,
|
||||
_ => 1.0f,
|
||||
},
|
||||
// Canid / Felid / Leporid / Imperium / "none" / unknown:
|
||||
_ => 1.0f,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience wrapper that resolves a character's effective size for
|
||||
/// the lookup (handles Phase 6.5 hybrid dominant-lineage rules).
|
||||
/// </summary>
|
||||
public static float GetCostMultiplier(Character character, string builtBy)
|
||||
{
|
||||
if (character is null) return 1.0f;
|
||||
var effectiveSize = EffectiveSize(character);
|
||||
return GetCostMultiplier(effectiveSize, builtBy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the size category that drives the clade-responsive lookup.
|
||||
/// For purebred PCs, this is just <see cref="Character.Size"/>. For
|
||||
/// hybrid PCs, it's the size implied by the dominant-lineage species
|
||||
/// — and we expose this as a separate helper so callers (e.g. NPC
|
||||
/// mechanics that *also* need the presenting size) can reuse it.
|
||||
/// </summary>
|
||||
public static SizeCategory EffectiveSize(Character character)
|
||||
{
|
||||
if (character is null) throw new System.ArgumentNullException(nameof(character));
|
||||
if (!character.IsHybrid) return character.Size;
|
||||
// Hybrid: pick the size implied by the dominant parent's species.
|
||||
// The Hybrid record carries the species name only (string); the
|
||||
// species-to-size mapping lives on Character.Species (the
|
||||
// *presenting* species set at character creation per the dominant
|
||||
// lineage). So for a hybrid PC the simplest (and load-bearing-
|
||||
// correct) answer is the presenting species — which is exactly
|
||||
// what <c>character.Size</c> already returns. Documented here so
|
||||
// future agents don't replace this with a parent-species lookup
|
||||
// and break the contract.
|
||||
return character.Size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.World;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — runtime view of an interior dungeon. Generated lazily on
|
||||
/// the player's first entry to a PoI; persisted modifications live on
|
||||
/// <see cref="Theriapolis.Core.Persistence.DungeonStateSnapshot"/> and
|
||||
/// re-apply on reload.
|
||||
///
|
||||
/// The dungeon owns its own bounded tactical-tile array (the scene-swap
|
||||
/// model from Phase 7 plan §4.2): movement, combat, dialogue, and save/load
|
||||
/// all work the same as the surface, but the renderer reads tiles from
|
||||
/// <see cref="Tiles"/> instead of the chunk streamer.
|
||||
///
|
||||
/// Coordinate space: <c>Tiles[x, y]</c> where <c>x ∈ [0, W)</c> and
|
||||
/// <c>y ∈ [0, H)</c>. Every <see cref="Room"/>'s AABB falls within these
|
||||
/// bounds; corridor tiles between rooms are also in this array.
|
||||
/// </summary>
|
||||
public sealed class Dungeon
|
||||
{
|
||||
/// <summary>Source PoI id. Identity for save lookups.</summary>
|
||||
public int PoiId { get; }
|
||||
|
||||
/// <summary>Dungeon type — drives art family, default loot tier, etc.</summary>
|
||||
public PoiType Type { get; }
|
||||
|
||||
/// <summary>The tactical-tile grid in dungeon-local coordinates.</summary>
|
||||
public TacticalTile[,] Tiles { get; }
|
||||
|
||||
/// <summary>Every room in layout order. <c>Rooms[i].Id == i</c>.</summary>
|
||||
public Room[] Rooms { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Door-anchored connections between rooms. Authoritative for
|
||||
/// reachability — the graph is undirected (a connection from A→B is
|
||||
/// implicit B→A; do not double-store).
|
||||
/// </summary>
|
||||
public RoomConnection[] Connections { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Dungeon-local tile coords of the entrance. The player spawns here
|
||||
/// on enter and exits when they cross this tile inbound from inside.
|
||||
/// </summary>
|
||||
public (int X, int Y) EntranceTile { get; }
|
||||
|
||||
/// <summary>Width of the tile array (dungeon-local tiles).</summary>
|
||||
public int W => Tiles.GetLength(0);
|
||||
/// <summary>Height of the tile array (dungeon-local tiles).</summary>
|
||||
public int H => Tiles.GetLength(1);
|
||||
|
||||
public Dungeon(
|
||||
int poiId,
|
||||
PoiType type,
|
||||
TacticalTile[,] tiles,
|
||||
Room[] rooms,
|
||||
RoomConnection[] connections,
|
||||
(int X, int Y) entranceTile)
|
||||
{
|
||||
PoiId = poiId;
|
||||
Type = type;
|
||||
Tiles = tiles;
|
||||
Rooms = rooms;
|
||||
Connections = connections;
|
||||
EntranceTile = entranceTile;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.World;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — top-level deterministic entry point for generating the
|
||||
/// interior of a PoI on first visit.
|
||||
///
|
||||
/// Determinism contract: <c>(worldSeed, poiId)</c> → byte-identical
|
||||
/// <see cref="Dungeon"/> across runs. Internally:
|
||||
/// <c>dungeonLayoutSeed = worldSeed ^ C.RNG_DUNGEON_LAYOUT ^ poiId</c>
|
||||
///
|
||||
/// Anchor-locked PoIs (Old Howl mine, Imperium Ruin showcase, Phase 7 M5
|
||||
/// content) bypass the procedural pipeline by routing to a pinned-rooms
|
||||
/// layout JSON. The pinned layout names the exact templates to use, in
|
||||
/// order; the assembler's branching policy still applies (typically
|
||||
/// linear). M1 does NOT pin any specific anchor PoI to a layout — that
|
||||
/// wiring lands in M5 alongside <c>side_act_i_old_howl.json</c> and the
|
||||
/// showcase rebuild. M1 ships the routing infrastructure.
|
||||
/// </summary>
|
||||
public static class DungeonGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure deterministic generator. Caller supplies the PoI's id (used as
|
||||
/// the per-dungeon sub-seed nonce), the dungeon type (drives layout
|
||||
/// selection + tile family), and the resolved content.
|
||||
///
|
||||
/// <paramref name="anchorOverride"/> — optional anchor id; when set,
|
||||
/// the generator looks up
|
||||
/// <see cref="ContentResolver.DungeonLayoutsByAnchor"/> first. M1
|
||||
/// callers leave it null; M5+ wires the Old Howl + Imperium showcase
|
||||
/// anchor routing.
|
||||
/// </summary>
|
||||
public static Dungeon Generate(
|
||||
ulong worldSeed,
|
||||
int poiId,
|
||||
PoiType type,
|
||||
ContentResolver content,
|
||||
string? anchorOverride = null)
|
||||
{
|
||||
if (content is null) throw new ArgumentNullException(nameof(content));
|
||||
|
||||
ulong layoutSeed = worldSeed ^ C.RNG_DUNGEON_LAYOUT ^ (ulong)poiId;
|
||||
|
||||
DungeonLayoutDef layout = ResolveLayout(type, content, anchorOverride);
|
||||
|
||||
// Build the room-graph plan.
|
||||
RoomGraphAssembler.Plan plan;
|
||||
if (layout.PinnedRooms.Length > 0)
|
||||
{
|
||||
plan = AssemblePinnedLayout(layout, content, layoutSeed);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Procedural layout — pick from typed templates.
|
||||
string typeKey = TypeKeyFor(type);
|
||||
if (!content.RoomTemplatesByType.TryGetValue(typeKey, out var typeTemplates) || typeTemplates.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"No room templates for dungeon type '{typeKey}' (PoiType.{type}). " +
|
||||
"Did you author Content/Data/room_templates/{type}/*.json?");
|
||||
|
||||
plan = DungeonLayoutBuilder.Build(layout, typeTemplates, layoutSeed);
|
||||
}
|
||||
|
||||
// Paint the tiles.
|
||||
var tiles = RoomTilePainter.Paint(plan, content.RoomTemplates, type);
|
||||
|
||||
return new Dungeon(
|
||||
poiId: poiId,
|
||||
type: type,
|
||||
tiles: tiles,
|
||||
rooms: plan.Rooms,
|
||||
connections: plan.Connections,
|
||||
entranceTile: plan.EntranceTile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find a procedural layout for the given type. M1 picks the first
|
||||
/// matching layout deterministically (room-count band tied to layout
|
||||
/// id); M2+ may add LevelBand-driven small/medium/large selection.
|
||||
/// </summary>
|
||||
private static DungeonLayoutDef ResolveLayout(PoiType type, ContentResolver content, string? anchorOverride)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(anchorOverride)
|
||||
&& content.DungeonLayoutsByAnchor.TryGetValue(anchorOverride, out var pinned))
|
||||
return pinned;
|
||||
|
||||
// Find the first non-anchor layout for this type. Stable order:
|
||||
// dictionary iteration is unordered in C# but DungeonLayouts is built
|
||||
// from a sorted file list (LoadDungeonLayouts orders by file path),
|
||||
// so iteration follows that order on .NET 8.
|
||||
foreach (var l in content.DungeonLayouts.Values)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(l.Anchor)) continue;
|
||||
if (string.Equals(l.DungeonType, type.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
return l;
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
$"No dungeon layout found for PoiType.{type}. " +
|
||||
"Author Content/Data/dungeon_layouts/<type>_<size>.json.");
|
||||
}
|
||||
|
||||
private static string TypeKeyFor(PoiType type) => type switch
|
||||
{
|
||||
PoiType.ImperiumRuin => "imperium",
|
||||
PoiType.AbandonedMine => "mine",
|
||||
PoiType.CultDen => "cult",
|
||||
PoiType.NaturalCave => "cave",
|
||||
PoiType.OvergrownSettlement => "overgrown",
|
||||
_ => "imperium",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build a plan from a pinned-rooms layout. Pinned layouts always use
|
||||
/// linear branching (the canonical Old Howl + Imperium showcase shape)
|
||||
/// — no retry, no fallback, no random pick.
|
||||
/// </summary>
|
||||
private static RoomGraphAssembler.Plan AssemblePinnedLayout(
|
||||
DungeonLayoutDef layout, ContentResolver content, ulong layoutSeed)
|
||||
{
|
||||
var picks = new List<RoomTemplateDef>(layout.PinnedRooms.Length);
|
||||
var roles = new List<RoomRole>(layout.PinnedRooms.Length);
|
||||
foreach (var pin in layout.PinnedRooms)
|
||||
{
|
||||
if (!content.RoomTemplates.TryGetValue(pin.Template, out var def))
|
||||
throw new InvalidOperationException(
|
||||
$"Pinned layout '{layout.Id}' references unknown template '{pin.Template}'. " +
|
||||
"ContentLoader should have caught this — re-run content-validate.");
|
||||
picks.Add(def);
|
||||
roles.Add(RoomRoleExtensions.Parse(pin.Role));
|
||||
}
|
||||
var rng = new Util.SeededRng(layoutSeed);
|
||||
var plan = RoomGraphAssembler.TryAssemble(picks, roles, "linear", rng);
|
||||
if (plan is null)
|
||||
throw new InvalidOperationException(
|
||||
$"Pinned layout '{layout.Id}' failed to assemble. Pinned layouts must be hand-validated.");
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — picks room templates per layout, then hands them to
|
||||
/// <see cref="RoomGraphAssembler"/>. Pure deterministic given the seed
|
||||
/// and layout.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Roll <c>roomCount</c> in [<c>RoomCountMin</c>, <c>RoomCountMax</c>].
|
||||
/// 2. Pick the entry-room template (filtered to <c>role: entry</c>).
|
||||
/// 3. Reserve slots for required roles (boss, narrative, etc.). Insert
|
||||
/// them last so they don't get crowded out by transit picks.
|
||||
/// 4. Fill remaining slots with transit / loot / dead-end picks.
|
||||
/// 5. Hand the template list + role list to the assembler.
|
||||
/// 6. On assembly failure, retry up to
|
||||
/// <c>C.DUNGEON_LAYOUT_MAX_ATTEMPTS</c> times. If all retries fail,
|
||||
/// fall back to a guaranteed-valid linear chain of N transit rooms.
|
||||
///
|
||||
/// Anchor-locked layouts (Old Howl mine, Imperium showcase) bypass this
|
||||
/// pipeline — <see cref="DungeonGenerator"/> short-circuits to the pinned
|
||||
/// template list directly.
|
||||
/// </summary>
|
||||
internal static class DungeonLayoutBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a fully-assembled <see cref="RoomGraphAssembler.Plan"/> for
|
||||
/// the given layout + content. Returns the plan from the first
|
||||
/// successful attempt; falls back to the linear-chain plan if every
|
||||
/// attempt fails.
|
||||
/// </summary>
|
||||
public static RoomGraphAssembler.Plan Build(
|
||||
DungeonLayoutDef layout,
|
||||
IReadOnlyList<RoomTemplateDef> typeTemplates,
|
||||
ulong layoutSeed)
|
||||
{
|
||||
if (layout is null) throw new ArgumentNullException(nameof(layout));
|
||||
if (typeTemplates is null || typeTemplates.Count == 0)
|
||||
throw new ArgumentException("no templates available for this dungeon type", nameof(typeTemplates));
|
||||
|
||||
var rng = new SeededRng(layoutSeed);
|
||||
|
||||
for (int attempt = 0; attempt < C.DUNGEON_LAYOUT_MAX_ATTEMPTS; attempt++)
|
||||
{
|
||||
int roomCount = rng.NextInt(layout.RoomCountMin, layout.RoomCountMax + 1);
|
||||
var (picks, roles) = PickTemplates(layout, typeTemplates, roomCount, rng);
|
||||
if (picks is null) continue;
|
||||
|
||||
var plan = RoomGraphAssembler.TryAssemble(picks, roles!, layout.Branching, rng);
|
||||
if (plan is not null) return plan;
|
||||
}
|
||||
|
||||
// Fallback: guaranteed-valid linear chain.
|
||||
return BuildLinearFallback(layout, typeTemplates, rng);
|
||||
}
|
||||
|
||||
private static (RoomTemplateDef[]? picks, RoomRole[]? roles) PickTemplates(
|
||||
DungeonLayoutDef layout,
|
||||
IReadOnlyList<RoomTemplateDef> typeTemplates,
|
||||
int roomCount,
|
||||
SeededRng rng)
|
||||
{
|
||||
// Group templates by eligible role for fast picking.
|
||||
var byRole = new Dictionary<RoomRole, List<RoomTemplateDef>>();
|
||||
foreach (var t in typeTemplates)
|
||||
foreach (var roleTag in t.RolesEligible)
|
||||
{
|
||||
RoomRole role;
|
||||
try { role = RoomRoleExtensions.Parse(roleTag); } catch { continue; }
|
||||
if (!byRole.TryGetValue(role, out var list))
|
||||
byRole[role] = list = new List<RoomTemplateDef>();
|
||||
list.Add(t);
|
||||
}
|
||||
|
||||
// Required roles must each be satisfiable.
|
||||
var requiredRoles = new List<RoomRole>();
|
||||
foreach (var raw in layout.RequiredRoles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var r = RoomRoleExtensions.Parse(raw);
|
||||
if (!byRole.ContainsKey(r) || byRole[r].Count == 0) return (null, null);
|
||||
requiredRoles.Add(r);
|
||||
}
|
||||
catch { return (null, null); }
|
||||
}
|
||||
|
||||
// 1. Entry: pick from "entry" pool (always required).
|
||||
if (!byRole.TryGetValue(RoomRole.Entry, out var entryPool) || entryPool.Count == 0) return (null, null);
|
||||
var entryPick = PickWeighted(entryPool, rng);
|
||||
|
||||
var picks = new List<RoomTemplateDef> { entryPick };
|
||||
var roles = new List<RoomRole> { RoomRole.Entry };
|
||||
|
||||
// 2. Reserve required-role slots (excluding entry, which is implicit).
|
||||
var deferred = new List<RoomRole>();
|
||||
foreach (var r in requiredRoles)
|
||||
{
|
||||
if (r == RoomRole.Entry) continue; // already placed
|
||||
deferred.Add(r);
|
||||
}
|
||||
|
||||
// 3. Fill remaining slots with transit / loot / dead-end.
|
||||
var optionalRoles = new List<RoomRole>();
|
||||
foreach (var raw in layout.OptionalRoles)
|
||||
{
|
||||
try { optionalRoles.Add(RoomRoleExtensions.Parse(raw)); } catch { /* ignore */ }
|
||||
}
|
||||
if (optionalRoles.Count == 0) optionalRoles.Add(RoomRole.Transit);
|
||||
|
||||
int slotsLeft = roomCount - 1 - deferred.Count;
|
||||
if (slotsLeft < 0) return (null, null); // required-role count exceeds room count
|
||||
|
||||
for (int i = 0; i < slotsLeft; i++)
|
||||
{
|
||||
var role = optionalRoles[rng.NextInt(0, optionalRoles.Count)];
|
||||
if (!byRole.TryGetValue(role, out var pool) || pool.Count == 0)
|
||||
role = RoomRole.Transit;
|
||||
// Fall back to typeTemplates if the role pool is empty.
|
||||
var fromPool = byRole.TryGetValue(role, out var p) && p.Count > 0
|
||||
? PickWeighted(p, rng)
|
||||
: PickWeighted(typeTemplates, rng);
|
||||
picks.Add(fromPool);
|
||||
roles.Add(role);
|
||||
}
|
||||
|
||||
// 4. Append deferred required-role picks (boss last so it ends the layout).
|
||||
deferred.Sort((a, b) => RolePriority(a).CompareTo(RolePriority(b)));
|
||||
foreach (var r in deferred)
|
||||
{
|
||||
picks.Add(PickWeighted(byRole[r], rng));
|
||||
roles.Add(r);
|
||||
}
|
||||
|
||||
return (picks.ToArray(), roles.ToArray());
|
||||
}
|
||||
|
||||
private static int RolePriority(RoomRole r) => r switch
|
||||
{
|
||||
RoomRole.Narrative => 0,
|
||||
RoomRole.Loot => 1,
|
||||
RoomRole.DeadEnd => 2,
|
||||
RoomRole.Boss => 9, // boss room last
|
||||
_ => 5,
|
||||
};
|
||||
|
||||
private static RoomTemplateDef PickWeighted(IReadOnlyList<RoomTemplateDef> pool, SeededRng rng)
|
||||
{
|
||||
if (pool.Count == 1) return pool[0];
|
||||
float total = 0f;
|
||||
foreach (var t in pool) total += t.Weight > 0 ? t.Weight : 1f;
|
||||
float roll = rng.NextFloat() * total;
|
||||
float acc = 0f;
|
||||
foreach (var t in pool)
|
||||
{
|
||||
acc += t.Weight > 0 ? t.Weight : 1f;
|
||||
if (roll <= acc) return t;
|
||||
}
|
||||
return pool[pool.Count - 1];
|
||||
}
|
||||
|
||||
private static RoomGraphAssembler.Plan BuildLinearFallback(
|
||||
DungeonLayoutDef layout,
|
||||
IReadOnlyList<RoomTemplateDef> typeTemplates,
|
||||
SeededRng rng)
|
||||
{
|
||||
// Linear chain: entry → N transits → boss (if required). The pure
|
||||
// last-resort path; any layout reaches it via a deterministic-but-
|
||||
// logged rng state.
|
||||
var entryPool = typeTemplates.Where(t => t.RolesEligible.Contains("entry", StringComparer.OrdinalIgnoreCase)).ToList();
|
||||
if (entryPool.Count == 0) entryPool = typeTemplates.ToList();
|
||||
var transitPool = typeTemplates.Where(t => t.RolesEligible.Contains("transit", StringComparer.OrdinalIgnoreCase)).ToList();
|
||||
if (transitPool.Count == 0) transitPool = typeTemplates.ToList();
|
||||
var bossPool = typeTemplates.Where(t => t.RolesEligible.Contains("boss", StringComparer.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
bool wantsBoss = layout.RequiredRoles.Contains("boss", StringComparer.OrdinalIgnoreCase) && bossPool.Count > 0;
|
||||
|
||||
int roomCount = layout.RoomCountMin;
|
||||
var picks = new List<RoomTemplateDef> { PickWeighted(entryPool, rng) };
|
||||
var roles = new List<RoomRole> { RoomRole.Entry };
|
||||
|
||||
int transitCount = wantsBoss ? roomCount - 2 : roomCount - 1;
|
||||
for (int i = 0; i < transitCount; i++)
|
||||
{
|
||||
picks.Add(PickWeighted(transitPool, rng));
|
||||
roles.Add(RoomRole.Transit);
|
||||
}
|
||||
if (wantsBoss)
|
||||
{
|
||||
picks.Add(PickWeighted(bossPool, rng));
|
||||
roles.Add(RoomRole.Boss);
|
||||
}
|
||||
|
||||
// Force the linear branching policy here regardless of layout; the
|
||||
// fallback exists exactly because branching/loop assembly failed.
|
||||
var plan = RoomGraphAssembler.TryAssemble(picks, roles, "linear", rng);
|
||||
if (plan is null)
|
||||
{
|
||||
// Truly degenerate — single-room dungeon (entry only).
|
||||
var only = picks[0];
|
||||
int pad = C.DUNGEON_AABB_PADDING;
|
||||
var rooms = new[]
|
||||
{
|
||||
new Room
|
||||
{
|
||||
Id = 0, TemplateId = only.Id,
|
||||
AabbX = pad, AabbY = pad,
|
||||
AabbW = only.FootprintWTiles, AabbH = only.FootprintHTiles,
|
||||
BuiltBy = only.BuiltBy, Role = RoomRole.Entry,
|
||||
NarrativeText = only.NarrativeText,
|
||||
},
|
||||
};
|
||||
(int X, int Y) entrance = only.Doors.Length > 0
|
||||
? (pad + only.Doors[0].X, pad + only.Doors[0].Y)
|
||||
: (pad + only.FootprintWTiles / 2, pad);
|
||||
return new RoomGraphAssembler.Plan(
|
||||
rooms, Array.Empty<RoomConnection>(),
|
||||
only.FootprintWTiles + 2 * pad,
|
||||
only.FootprintHTiles + 2 * pad,
|
||||
entrance);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Items;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M2 — pre-instantiated population of a generated dungeon. Holds:
|
||||
/// - The list of <see cref="DungeonSpawn"/> entries (one per encounter
|
||||
/// slot in any room) — coordinates + template + role tag.
|
||||
/// - The list of <see cref="DungeonContainer"/> entries (one per
|
||||
/// container slot) — coordinates + table id + pre-rolled item drops.
|
||||
///
|
||||
/// Generated alongside (and from) a <see cref="Dungeon"/> via
|
||||
/// <see cref="DungeonPopulator.Populate"/>. Living combatants and item
|
||||
/// pickups derive from these records on first dungeon entry; the
|
||||
/// <see cref="Theriapolis.Core.Persistence.DungeonStateSnapshot"/>
|
||||
/// persists which entries have been resolved (cleared / looted) so
|
||||
/// re-entry doesn't re-spawn them.
|
||||
/// </summary>
|
||||
public sealed class DungeonPopulation
|
||||
{
|
||||
public DungeonSpawn[] Spawns { get; }
|
||||
public DungeonContainer[] Containers { get; }
|
||||
|
||||
public DungeonPopulation(DungeonSpawn[] spawns, DungeonContainer[] containers)
|
||||
{
|
||||
Spawns = spawns;
|
||||
Containers = containers;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>@</c> encounter slot in a generated dungeon — resolved to the
|
||||
/// concrete NPC template that fills it. The dungeon coords are absolute
|
||||
/// (within the dungeon's tile array, not template-local).
|
||||
/// </summary>
|
||||
public readonly record struct DungeonSpawn(
|
||||
/// <summary>Index into <see cref="Dungeon.Rooms"/> the spawn lives in.</summary>
|
||||
int RoomId,
|
||||
/// <summary>Dungeon-local tile-X.</summary>
|
||||
int X,
|
||||
/// <summary>Dungeon-local tile-Y.</summary>
|
||||
int Y,
|
||||
/// <summary>The chosen NPC template. Caller instantiates from this.</summary>
|
||||
NpcTemplateDef Template,
|
||||
/// <summary>Spawn-kind tag the slot declared (PoiGuard / WildAnimal / Brigand / Boss).</summary>
|
||||
string Kind);
|
||||
|
||||
/// <summary>
|
||||
/// One <c>C</c> container slot in a generated dungeon — resolved to the
|
||||
/// concrete loot drop. Populated at generation time so the same
|
||||
/// <c>(worldSeed, poiId, slotIdx)</c> always rolls identical items.
|
||||
/// </summary>
|
||||
public readonly record struct DungeonContainer(
|
||||
/// <summary>Index into <see cref="Dungeon.Rooms"/> the container is in.</summary>
|
||||
int RoomId,
|
||||
/// <summary>Dungeon-local tile-X.</summary>
|
||||
int X,
|
||||
/// <summary>Dungeon-local tile-Y.</summary>
|
||||
int Y,
|
||||
/// <summary>Loot-table id consulted at populate time.</summary>
|
||||
string TableId,
|
||||
/// <summary>Pre-rolled item drops, ready to transfer on player loot.</summary>
|
||||
ItemInstance[] Drops,
|
||||
/// <summary>True when the slot's grid char declared <c>locked</c>.</summary>
|
||||
bool Locked,
|
||||
/// <summary>Lock difficulty tier ("trivial"/"easy"/"medium"/"hard"; empty when unlocked).</summary>
|
||||
string LockTier);
|
||||
@@ -0,0 +1,195 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Loot;
|
||||
using Theriapolis.Core.World;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M2 — populate a generated <see cref="Dungeon"/>'s encounter
|
||||
/// and container slots. Pure deterministic given the same inputs:
|
||||
/// <c>(worldSeed, poiId, dungeon, content, levelBand)</c>.
|
||||
///
|
||||
/// For each room:
|
||||
/// - Walk the room's source <see cref="RoomTemplateDef.EncounterSlots"/>
|
||||
/// and resolve each slot to a concrete <see cref="NpcTemplateDef"/> via
|
||||
/// <see cref="NpcTemplateContent.SpawnKindToTemplateByDungeonType"/>
|
||||
/// (with a fallback to <see cref="NpcTemplateContent.SpawnKindToTemplateByZone"/>
|
||||
/// middle tier if the per-dungeon-type table doesn't list that kind).
|
||||
/// - Walk the source <see cref="RoomTemplateDef.ContainerSlots"/> and
|
||||
/// pre-roll each container's loot via
|
||||
/// <see cref="LootGenerator.RollContainer"/>, mapping the container's
|
||||
/// <c>loot_table_band</c> through the layout's
|
||||
/// <c>loot_table_per_band</c> to a concrete table id.
|
||||
///
|
||||
/// Boss-room encounter slots use the dungeon-type's <c>"Boss"</c>
|
||||
/// template if available, otherwise fall back to the <c>"PoiGuard"</c>
|
||||
/// template — no boss → strong-guard graceful degradation.
|
||||
///
|
||||
/// Per-NPC-spawn RNG sub-seed:
|
||||
/// <c>populateSeed = dungeonLayoutSeed ^ C.RNG_DUNGEON_POPULATE ^ roomId ^ slotIdx</c>
|
||||
/// — present so future variant rolls (e.g. random which-of-N-equivalent-
|
||||
/// templates) stay deterministic. M2 doesn't read it (per-kind template
|
||||
/// is fixed by the override map) but the helper is wired up so M3+
|
||||
/// content can use it without a contract change.
|
||||
/// </summary>
|
||||
public static class DungeonPopulator
|
||||
{
|
||||
/// <summary>
|
||||
/// Populate a freshly-generated dungeon. <paramref name="layoutId"/>
|
||||
/// is the dungeon-layout id (from <see cref="DungeonLayoutDef.Id"/>);
|
||||
/// the populator looks it up via <paramref name="content"/> to read
|
||||
/// loot-band → table mappings. <paramref name="levelBand"/> is the
|
||||
/// PoI's authored level band (0..3) and selects which loot tier
|
||||
/// each container slot rolls.
|
||||
/// </summary>
|
||||
public static DungeonPopulation Populate(
|
||||
Dungeon dungeon,
|
||||
DungeonLayoutDef layout,
|
||||
ContentResolver content,
|
||||
int levelBand,
|
||||
ulong worldSeed)
|
||||
{
|
||||
if (dungeon is null) throw new System.ArgumentNullException(nameof(dungeon));
|
||||
if (layout is null) throw new System.ArgumentNullException(nameof(layout));
|
||||
if (content is null) throw new System.ArgumentNullException(nameof(content));
|
||||
|
||||
ulong dungeonLayoutSeed = worldSeed ^ C.RNG_DUNGEON_LAYOUT ^ (ulong)dungeon.PoiId;
|
||||
|
||||
// Resolve loot-band → table-id mapping for this layout + level band.
|
||||
string lootBand = ResolveLootBand(layout, levelBand);
|
||||
layout.LootTablePerBand.TryGetValue(lootBand, out var lootTableForBand);
|
||||
|
||||
var spawns = new List<DungeonSpawn>();
|
||||
var containers = new List<DungeonContainer>();
|
||||
|
||||
// Per-dungeon spawn-kind resolver lookup.
|
||||
string typeKey = dungeon.Type.ToString();
|
||||
content.Npcs.SpawnKindToTemplateByDungeonType.TryGetValue(typeKey, out var kindMap);
|
||||
|
||||
int globalContainerIdx = 0;
|
||||
int globalSpawnIdx = 0;
|
||||
|
||||
foreach (var room in dungeon.Rooms)
|
||||
{
|
||||
if (!content.RoomTemplates.TryGetValue(room.TemplateId, out var def))
|
||||
continue;
|
||||
|
||||
// Encounter slots → concrete spawns.
|
||||
foreach (var slot in def.EncounterSlots)
|
||||
{
|
||||
var template = ResolveSpawnTemplate(slot.Kind, room.Role, kindMap, content.Npcs, dungeon.Type);
|
||||
if (template is null) { globalSpawnIdx++; continue; }
|
||||
|
||||
int absX = room.AabbX + slot.X;
|
||||
int absY = room.AabbY + slot.Y;
|
||||
spawns.Add(new DungeonSpawn(
|
||||
RoomId: room.Id,
|
||||
X: absX,
|
||||
Y: absY,
|
||||
Template: template,
|
||||
Kind: slot.Kind));
|
||||
globalSpawnIdx++;
|
||||
}
|
||||
|
||||
// Container slots → pre-rolled loot.
|
||||
foreach (var slot in def.ContainerSlots)
|
||||
{
|
||||
int absX = room.AabbX + slot.X;
|
||||
int absY = room.AabbY + slot.Y;
|
||||
// Per-container band: room's container slot may declare its
|
||||
// own band (e.g. boss room slot says "t3"); otherwise we
|
||||
// use the layout's level-band → loot-band lookup.
|
||||
string slotBand = !string.IsNullOrEmpty(slot.LootTableBand) ? slot.LootTableBand : lootBand;
|
||||
layout.LootTablePerBand.TryGetValue(slotBand, out var slotTableId);
|
||||
slotTableId ??= lootTableForBand ?? "";
|
||||
|
||||
var drops = string.IsNullOrEmpty(slotTableId)
|
||||
? System.Array.Empty<ItemInstance>()
|
||||
: LootGenerator.RollContainer(
|
||||
tableId: slotTableId,
|
||||
dungeonLayoutSeed: dungeonLayoutSeed,
|
||||
slotIdx: globalContainerIdx,
|
||||
tables: content.LootTables,
|
||||
items: content.Items);
|
||||
|
||||
containers.Add(new DungeonContainer(
|
||||
RoomId: room.Id,
|
||||
X: absX,
|
||||
Y: absY,
|
||||
TableId: slotTableId ?? "",
|
||||
Drops: drops,
|
||||
Locked: slot.Locked,
|
||||
LockTier: slot.Lock));
|
||||
globalContainerIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
return new DungeonPopulation(spawns.ToArray(), containers.ToArray());
|
||||
}
|
||||
|
||||
private static string ResolveLootBand(DungeonLayoutDef layout, int levelBand)
|
||||
{
|
||||
var key = levelBand.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (layout.LevelBandToLootBand.TryGetValue(key, out var band) && !string.IsNullOrEmpty(band))
|
||||
return band;
|
||||
// Default per the Phase 7 plan §5.5 thresholds:
|
||||
// levelBand 0..1 → t1, 2 → t2, 3+ → t3.
|
||||
return levelBand switch
|
||||
{
|
||||
<= 1 => "t1",
|
||||
2 => "t2",
|
||||
_ => "t3",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a slot's spawn-kind tag to a concrete NPC template. Boss
|
||||
/// slots get the dungeon-type's "Boss" template if listed; otherwise
|
||||
/// the slot's own kind, with a graceful fall-through to the
|
||||
/// existing per-zone table at the mid tier.
|
||||
/// </summary>
|
||||
private static NpcTemplateDef? ResolveSpawnTemplate(
|
||||
string slotKind,
|
||||
RoomRole roomRole,
|
||||
IReadOnlyDictionary<string, string>? kindMap,
|
||||
NpcTemplateContent npcs,
|
||||
PoiType dungeonType)
|
||||
{
|
||||
// Boss-role rooms with a boss kind: prefer the per-dungeon-type
|
||||
// "Boss" entry. If neither the slot says "Boss" nor the room is
|
||||
// a boss room, this branch doesn't fire.
|
||||
string effectiveKind = slotKind;
|
||||
if (roomRole == RoomRole.Boss && string.Equals(slotKind, "Boss", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveKind = "Boss";
|
||||
}
|
||||
|
||||
// 1. Per-dungeon-type override.
|
||||
if (kindMap is not null && kindMap.TryGetValue(effectiveKind, out var tplId))
|
||||
{
|
||||
foreach (var t in npcs.Templates)
|
||||
if (string.Equals(t.Id, tplId, System.StringComparison.OrdinalIgnoreCase))
|
||||
return t;
|
||||
}
|
||||
|
||||
// 2. Boss kind unmapped → fall back to PoiGuard for the same dungeon type.
|
||||
if (effectiveKind == "Boss" && kindMap is not null && kindMap.TryGetValue("PoiGuard", out var guardId))
|
||||
{
|
||||
foreach (var t in npcs.Templates)
|
||||
if (string.Equals(t.Id, guardId, System.StringComparison.OrdinalIgnoreCase))
|
||||
return t;
|
||||
}
|
||||
|
||||
// 3. Final fallback: the per-zone table at zone 2 (mid).
|
||||
if (npcs.SpawnKindToTemplateByZone.TryGetValue(slotKind, out var byZone) && byZone.Length > 0)
|
||||
{
|
||||
int z = System.Math.Min(2, byZone.Length - 1);
|
||||
string id = byZone[z];
|
||||
foreach (var t in npcs.Templates)
|
||||
if (string.Equals(t.Id, id, System.StringComparison.OrdinalIgnoreCase))
|
||||
return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — runtime state for a single room inside a generated dungeon.
|
||||
///
|
||||
/// A Room records its template id, its dungeon-local axis-aligned bounding
|
||||
/// box (top-left corner + dimensions, in tactical tiles), the clade that
|
||||
/// built it (drives clade-responsive movement cost per Phase 7 plan §5.4),
|
||||
/// and the role it occupies in the dungeon's layout. Mutable runtime state
|
||||
/// (cleared / looted flags) lives on the <see cref="DungeonState"/>
|
||||
/// snapshot, not here, so this record stays a deterministic baseline view.
|
||||
/// </summary>
|
||||
public sealed class Room
|
||||
{
|
||||
/// <summary>Stable id within a dungeon (0..Dungeon.Rooms.Length-1).</summary>
|
||||
public int Id { get; init; }
|
||||
|
||||
/// <summary>Reference back to the source <see cref="Theriapolis.Core.Data.RoomTemplateDef.Id"/>.</summary>
|
||||
public string TemplateId { get; init; } = "";
|
||||
|
||||
/// <summary>Dungeon-local AABB top-left X (tile units). Inclusive.</summary>
|
||||
public int AabbX { get; init; }
|
||||
/// <summary>Dungeon-local AABB top-left Y (tile units). Inclusive.</summary>
|
||||
public int AabbY { get; init; }
|
||||
/// <summary>AABB width in tactical tiles (matches the source template's <c>footprint_w_tiles</c>).</summary>
|
||||
public int AabbW { get; init; }
|
||||
/// <summary>AABB height in tactical tiles (matches the source template's <c>footprint_h_tiles</c>).</summary>
|
||||
public int AabbH { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Builder clade — drives the clade-responsive movement multiplier
|
||||
/// (<c>ClademorphicMovement.GetCostMultiplier</c>) the player pays
|
||||
/// while moving through this room. Empty / "none" means no penalty.
|
||||
/// </summary>
|
||||
public string BuiltBy { get; init; } = "none";
|
||||
|
||||
/// <summary>Role assigned at layout-build time.</summary>
|
||||
public RoomRole Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional environmental-storytelling prose surfaced by the
|
||||
/// InteractionScreen scent-overlay panel (Phase 6.5 M1) and the
|
||||
/// dungeon-clear coda. Null when the source template doesn't carry one.
|
||||
/// </summary>
|
||||
public string? NarrativeText { get; init; }
|
||||
|
||||
public override string ToString()
|
||||
=> $"Room[id={Id} role={Role} aabb=({AabbX},{AabbY},{AabbW}x{AabbH}) tpl={TemplateId}]";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — a connection between two rooms in a dungeon, anchored by
|
||||
/// the door tile on each side. Connections are created by
|
||||
/// <see cref="RoomGraphAssembler"/> when matching one room's outgoing door
|
||||
/// to another's incoming door; they're authoritative for reachability
|
||||
/// (BFS over connections, not over the painted tile array).
|
||||
///
|
||||
/// Door coordinates are in dungeon-local tile space and address the
|
||||
/// *door tile itself* (carved out of the perimeter wall). The door state
|
||||
/// (open / closed / locked) lives in <see cref="Theriapolis.Core.Persistence.DungeonStateSnapshot"/>
|
||||
/// — this record carries only the deterministic baseline.
|
||||
/// </summary>
|
||||
public readonly record struct RoomConnection(
|
||||
int RoomA,
|
||||
int DoorAx,
|
||||
int DoorAy,
|
||||
int RoomB,
|
||||
int DoorBx,
|
||||
int DoorBy,
|
||||
/// <summary>Lock difficulty tier or "" for unlocked. Matches RoomDoor.Lock values.</summary>
|
||||
string Lock = "");
|
||||
@@ -0,0 +1,376 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — pure deterministic room-graph assembly. Given a list of
|
||||
/// picked templates (in order: entry first, others in pick order) and a
|
||||
/// branching policy, returns:
|
||||
/// - Per-room placement (AABB top-left + Role)
|
||||
/// - List of <see cref="RoomConnection"/> binding rooms via matched
|
||||
/// door tiles
|
||||
///
|
||||
/// The assembler does NOT paint tiles — it only decides geometry. Tile
|
||||
/// painting + corridor stamping is <see cref="RoomTilePainter"/>'s job.
|
||||
///
|
||||
/// Placement algorithm: rooms snap to a 16-tile grid, placed left-to-right
|
||||
/// in chains; each non-entry room picks an existing-room neighbour from
|
||||
/// the eligible set (linear → previous; branching → uniform-random prior;
|
||||
/// loop → branching + one extra closing connection). The neighbour's
|
||||
/// available-side pool determines which AABB slot the new room takes.
|
||||
///
|
||||
/// Returns null on failure (overlap, unreachable). Caller retries with a
|
||||
/// fresh seed up to <see cref="C.DUNGEON_LAYOUT_MAX_ATTEMPTS"/> times then
|
||||
/// falls back to the linear policy.
|
||||
/// </summary>
|
||||
internal static class RoomGraphAssembler
|
||||
{
|
||||
public sealed class Plan
|
||||
{
|
||||
public Room[] Rooms;
|
||||
public RoomConnection[] Connections;
|
||||
public int DungeonW;
|
||||
public int DungeonH;
|
||||
public (int X, int Y) EntranceTile;
|
||||
|
||||
public Plan(Room[] rooms, RoomConnection[] connections, int w, int h, (int, int) entranceTile)
|
||||
{
|
||||
Rooms = rooms;
|
||||
Connections = connections;
|
||||
DungeonW = w;
|
||||
DungeonH = h;
|
||||
EntranceTile = entranceTile;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to assemble. <paramref name="branching"/> is one of
|
||||
/// <c>linear</c> / <c>branching</c> / <c>loop</c>. Returns null on
|
||||
/// any geometric failure; caller retries.
|
||||
/// </summary>
|
||||
public static Plan? TryAssemble(
|
||||
IReadOnlyList<RoomTemplateDef> picks,
|
||||
IReadOnlyList<RoomRole> roles,
|
||||
string branching,
|
||||
SeededRng rng)
|
||||
{
|
||||
if (picks.Count == 0) return null;
|
||||
if (picks.Count != roles.Count) throw new ArgumentException("picks and roles length mismatch");
|
||||
|
||||
// Step 1: place the entry room at the origin, padded by AABB padding.
|
||||
int pad = C.DUNGEON_AABB_PADDING;
|
||||
int gap = C.ROOM_INTER_ROOM_GAP_TILES;
|
||||
|
||||
var rooms = new Room[picks.Count];
|
||||
// Track absolute AABBs for overlap testing.
|
||||
var bounds = new (int x, int y, int w, int h)[picks.Count];
|
||||
|
||||
var entry = picks[0];
|
||||
rooms[0] = new Room
|
||||
{
|
||||
Id = 0,
|
||||
TemplateId = entry.Id,
|
||||
AabbX = pad,
|
||||
AabbY = pad,
|
||||
AabbW = entry.FootprintWTiles,
|
||||
AabbH = entry.FootprintHTiles,
|
||||
BuiltBy = entry.BuiltBy,
|
||||
Role = roles[0],
|
||||
NarrativeText = entry.NarrativeText,
|
||||
};
|
||||
bounds[0] = (pad, pad, entry.FootprintWTiles, entry.FootprintHTiles);
|
||||
|
||||
var connections = new List<RoomConnection>(picks.Count);
|
||||
|
||||
// Step 2: place each subsequent room next to a chosen prior room.
|
||||
for (int i = 1; i < picks.Count; i++)
|
||||
{
|
||||
int parentIdx = ChooseParent(branching, i, rng);
|
||||
var parent = bounds[parentIdx];
|
||||
var tpl = picks[i];
|
||||
|
||||
// Try each cardinal direction in a deterministic order; pick the
|
||||
// first that doesn't overlap. Order rotates per `i` so different
|
||||
// seeds produce different-looking layouts.
|
||||
int[] dirOrder = RotateDirOrder(i, rng);
|
||||
(int x, int y, int dir)? placement = null;
|
||||
foreach (int d in dirOrder)
|
||||
{
|
||||
var topLeft = TryPlaceAdjacent(parent, tpl.FootprintWTiles, tpl.FootprintHTiles, d, gap);
|
||||
if (topLeft is null) continue;
|
||||
var candBounds = (topLeft.Value.x, topLeft.Value.y, tpl.FootprintWTiles, tpl.FootprintHTiles);
|
||||
if (Overlaps(candBounds, bounds, i)) continue;
|
||||
placement = (topLeft.Value.x, topLeft.Value.y, d);
|
||||
break;
|
||||
}
|
||||
if (placement is null) return null; // ran out of room sides
|
||||
|
||||
rooms[i] = new Room
|
||||
{
|
||||
Id = i,
|
||||
TemplateId = tpl.Id,
|
||||
AabbX = placement.Value.x,
|
||||
AabbY = placement.Value.y,
|
||||
AabbW = tpl.FootprintWTiles,
|
||||
AabbH = tpl.FootprintHTiles,
|
||||
BuiltBy = tpl.BuiltBy,
|
||||
Role = roles[i],
|
||||
NarrativeText = tpl.NarrativeText,
|
||||
};
|
||||
bounds[i] = (placement.Value.x, placement.Value.y, tpl.FootprintWTiles, tpl.FootprintHTiles);
|
||||
|
||||
// Pick the door pair: parent's near-side door, this room's
|
||||
// far-side door. If neither template has a matching door we
|
||||
// synthesize a door at the AABB midpoint of the touching edge —
|
||||
// the painter will carve it through the wall.
|
||||
var conn = MatchDoors(rooms[parentIdx], picks[parentIdx], rooms[i], tpl, placement.Value.dir);
|
||||
connections.Add(conn);
|
||||
}
|
||||
|
||||
// Step 3 (loop policy only): add one extra closing connection if a
|
||||
// suitable pair exists. The closing connection must not duplicate
|
||||
// an existing edge.
|
||||
if (branching == "loop" && rooms.Length >= 4)
|
||||
{
|
||||
// Pick room i (not 0) and j > 1, j != i, with no existing edge.
|
||||
int triesLeft = 8;
|
||||
while (triesLeft-- > 0)
|
||||
{
|
||||
int i = rng.NextInt(2, rooms.Length);
|
||||
int j = rng.NextInt(2, rooms.Length);
|
||||
if (i == j) continue;
|
||||
if (HasEdge(connections, i, j)) continue;
|
||||
var dir = AdjacentDirection(bounds[i], bounds[j], gap + 2);
|
||||
if (dir is null) continue;
|
||||
connections.Add(MatchDoors(rooms[i], picks[i], rooms[j], picks[j], dir.Value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: BFS reachability — every room must be reachable from room 0.
|
||||
if (!IsReachable(rooms.Length, connections)) return null;
|
||||
|
||||
// Step 5: compute dungeon bounds.
|
||||
int minX = int.MaxValue, minY = int.MaxValue, maxX = 0, maxY = 0;
|
||||
foreach (var b in bounds)
|
||||
{
|
||||
if (b.x < minX) minX = b.x;
|
||||
if (b.y < minY) minY = b.y;
|
||||
if (b.x + b.w > maxX) maxX = b.x + b.w;
|
||||
if (b.y + b.h > maxY) maxY = b.y + b.h;
|
||||
}
|
||||
// Translate everything so origin is (pad, pad).
|
||||
int dx = pad - minX;
|
||||
int dy = pad - minY;
|
||||
if (dx != 0 || dy != 0)
|
||||
{
|
||||
for (int i = 0; i < rooms.Length; i++)
|
||||
{
|
||||
rooms[i] = new Room
|
||||
{
|
||||
Id = rooms[i].Id,
|
||||
TemplateId = rooms[i].TemplateId,
|
||||
AabbX = rooms[i].AabbX + dx,
|
||||
AabbY = rooms[i].AabbY + dy,
|
||||
AabbW = rooms[i].AabbW,
|
||||
AabbH = rooms[i].AabbH,
|
||||
BuiltBy = rooms[i].BuiltBy,
|
||||
Role = rooms[i].Role,
|
||||
NarrativeText = rooms[i].NarrativeText,
|
||||
};
|
||||
}
|
||||
for (int k = 0; k < connections.Count; k++)
|
||||
{
|
||||
var c = connections[k];
|
||||
connections[k] = c with
|
||||
{
|
||||
DoorAx = c.DoorAx + dx,
|
||||
DoorAy = c.DoorAy + dy,
|
||||
DoorBx = c.DoorBx + dx,
|
||||
DoorBy = c.DoorBy + dy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
int dungeonW = maxX - minX + 2 * pad;
|
||||
int dungeonH = maxY - minY + 2 * pad;
|
||||
|
||||
// Entrance tile — pick the entry room's first declared door if any,
|
||||
// otherwise the centre of its top edge. (M1 always has a door because
|
||||
// every authored entry template declares at least one.)
|
||||
var entryDoor = picks[0].Doors.Length > 0 ? picks[0].Doors[0] : null;
|
||||
(int X, int Y) entranceTile;
|
||||
if (entryDoor is not null)
|
||||
entranceTile = (rooms[0].AabbX + entryDoor.X, rooms[0].AabbY + entryDoor.Y);
|
||||
else
|
||||
entranceTile = (rooms[0].AabbX + rooms[0].AabbW / 2, rooms[0].AabbY);
|
||||
|
||||
return new Plan(rooms, connections.ToArray(), dungeonW, dungeonH, entranceTile);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static int ChooseParent(string branching, int childIdx, SeededRng rng) => branching switch
|
||||
{
|
||||
"linear" => childIdx - 1,
|
||||
"branching" => rng.NextInt(0, childIdx),
|
||||
"loop" => rng.NextInt(0, childIdx),
|
||||
_ => childIdx - 1,
|
||||
};
|
||||
|
||||
private static int[] RotateDirOrder(int i, SeededRng rng)
|
||||
{
|
||||
// Rotate base order by `i % 4` so adjacent rooms don't pile up on
|
||||
// the same axis. Add a small RNG-driven secondary shuffle so seeds
|
||||
// diverge.
|
||||
int[] baseOrder = new[] { 0, 1, 2, 3 }; // 0=E, 1=S, 2=W, 3=N
|
||||
int rot = i % 4;
|
||||
var rotated = new int[4];
|
||||
for (int k = 0; k < 4; k++) rotated[k] = baseOrder[(k + rot) % 4];
|
||||
// 50% chance to swap pairs (small variation)
|
||||
if ((rng.NextUInt64() & 1) == 1)
|
||||
{
|
||||
(rotated[0], rotated[2]) = (rotated[2], rotated[0]);
|
||||
}
|
||||
return rotated;
|
||||
}
|
||||
|
||||
private static (int x, int y)? TryPlaceAdjacent(
|
||||
(int x, int y, int w, int h) parent,
|
||||
int childW, int childH, int direction, int gap)
|
||||
{
|
||||
// direction: 0=E, 1=S, 2=W, 3=N. Child’s top-left is offset from
|
||||
// parent's outer edge by `gap` tiles so a corridor segment fits.
|
||||
return direction switch
|
||||
{
|
||||
0 => (parent.x + parent.w + gap, parent.y), // east
|
||||
1 => (parent.x, parent.y + parent.h + gap), // south
|
||||
2 => (parent.x - childW - gap, parent.y), // west
|
||||
3 => (parent.x, parent.y - childH - gap), // north
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool Overlaps(
|
||||
(int x, int y, int w, int h) cand,
|
||||
(int x, int y, int w, int h)[] bounds,
|
||||
int countSoFar)
|
||||
{
|
||||
for (int k = 0; k < countSoFar; k++)
|
||||
{
|
||||
var b = bounds[k];
|
||||
// AABB overlap: not (cand right of b OR cand left of b OR cand below b OR cand above b)
|
||||
bool noOverlap = cand.x + cand.w <= b.x
|
||||
|| b.x + b.w <= cand.x
|
||||
|| cand.y + cand.h <= b.y
|
||||
|| b.y + b.h <= cand.y;
|
||||
if (!noOverlap) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int? AdjacentDirection(
|
||||
(int x, int y, int w, int h) a,
|
||||
(int x, int y, int w, int h) b, int slack)
|
||||
{
|
||||
// Returns a direction code (0=E,1=S,2=W,3=N) for "b is to the X of
|
||||
// a within `slack` tiles" — used for loop-policy closing edges.
|
||||
if (Math.Abs((a.x + a.w) - b.x) <= slack && OverlapsRange(a.y, a.h, b.y, b.h))
|
||||
return 0;
|
||||
if (Math.Abs((a.y + a.h) - b.y) <= slack && OverlapsRange(a.x, a.w, b.x, b.w))
|
||||
return 1;
|
||||
if (Math.Abs(a.x - (b.x + b.w)) <= slack && OverlapsRange(a.y, a.h, b.y, b.h))
|
||||
return 2;
|
||||
if (Math.Abs(a.y - (b.y + b.h)) <= slack && OverlapsRange(a.x, a.w, b.x, b.w))
|
||||
return 3;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool OverlapsRange(int a0, int aLen, int b0, int bLen)
|
||||
=> !(a0 + aLen <= b0 || b0 + bLen <= a0);
|
||||
|
||||
private static bool HasEdge(List<RoomConnection> conns, int a, int b)
|
||||
{
|
||||
foreach (var c in conns)
|
||||
if ((c.RoomA == a && c.RoomB == b) || (c.RoomA == b && c.RoomB == a))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsReachable(int roomCount, List<RoomConnection> connections)
|
||||
{
|
||||
if (roomCount == 0) return true;
|
||||
var adj = new List<int>[roomCount];
|
||||
for (int i = 0; i < roomCount; i++) adj[i] = new List<int>();
|
||||
foreach (var c in connections)
|
||||
{
|
||||
adj[c.RoomA].Add(c.RoomB);
|
||||
adj[c.RoomB].Add(c.RoomA);
|
||||
}
|
||||
var visited = new bool[roomCount];
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(0);
|
||||
visited[0] = true;
|
||||
int reached = 1;
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
int n = queue.Dequeue();
|
||||
foreach (int m in adj[n])
|
||||
{
|
||||
if (visited[m]) continue;
|
||||
visited[m] = true;
|
||||
reached++;
|
||||
queue.Enqueue(m);
|
||||
}
|
||||
}
|
||||
return reached == roomCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Match door tiles between two rooms placed adjacent in
|
||||
/// <paramref name="direction"/>. Returns the connection record with
|
||||
/// dungeon-local door coords on each side. Falls back to the AABB
|
||||
/// midpoint of the touching edge when neither template declares a door
|
||||
/// on the relevant side.
|
||||
/// </summary>
|
||||
private static RoomConnection MatchDoors(
|
||||
Room a, RoomTemplateDef aDef,
|
||||
Room b, RoomTemplateDef bDef,
|
||||
int direction)
|
||||
{
|
||||
// Direction is from A's perspective: 0=B east of A; 1=B south; 2=B west; 3=B north.
|
||||
// Pick A's door on the matching side; pick B's door on the opposite side.
|
||||
string aFacing = direction switch { 0 => "E", 1 => "S", 2 => "W", 3 => "N", _ => "E" };
|
||||
string bFacing = direction switch { 0 => "W", 1 => "N", 2 => "E", 3 => "S", _ => "W" };
|
||||
|
||||
var aDoor = FindDoorByFacing(aDef, aFacing) ?? AabbEdgeMidpoint(aDef, aFacing);
|
||||
var bDoor = FindDoorByFacing(bDef, bFacing) ?? AabbEdgeMidpoint(bDef, bFacing);
|
||||
|
||||
return new RoomConnection(
|
||||
RoomA: a.Id, DoorAx: a.AabbX + aDoor.x, DoorAy: a.AabbY + aDoor.y,
|
||||
RoomB: b.Id, DoorBx: b.AabbX + bDoor.x, DoorBy: b.AabbY + bDoor.y,
|
||||
Lock: aDoor.lockTier);
|
||||
}
|
||||
|
||||
private static (int x, int y, string lockTier)? FindDoorByFacing(RoomTemplateDef def, string facing)
|
||||
{
|
||||
foreach (var d in def.Doors)
|
||||
if (string.Equals(d.Facing, facing, StringComparison.OrdinalIgnoreCase))
|
||||
return (d.X, d.Y, d.Lock);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (int x, int y, string lockTier) AabbEdgeMidpoint(RoomTemplateDef def, string facing)
|
||||
{
|
||||
int w = def.FootprintWTiles, h = def.FootprintHTiles;
|
||||
return facing switch
|
||||
{
|
||||
"E" => (w - 1, h / 2, ""),
|
||||
"W" => (0, h / 2, ""),
|
||||
"N" => (w / 2, 0, ""),
|
||||
"S" => (w / 2, h - 1, ""),
|
||||
_ => (w / 2, h / 2, ""),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// The slot a room occupies in a dungeon's role mix.
|
||||
///
|
||||
/// Each <see cref="Theriapolis.Core.Data.RoomTemplateDef"/> declares a list
|
||||
/// of role-eligibility tags via <c>roles_eligible</c>. The layout assembler
|
||||
/// pairs templates to roles when filling a dungeon's required + optional
|
||||
/// role slots; the resulting <see cref="Room"/> records its assigned role
|
||||
/// for content-distribution decisions (loot tier, encounter density, etc.).
|
||||
/// </summary>
|
||||
public enum RoomRole : byte
|
||||
{
|
||||
/// <summary>The dungeon's surface entrance. Always one per dungeon.</summary>
|
||||
Entry,
|
||||
/// <summary>Generic in-between room — most rooms in a typical dungeon are transit.</summary>
|
||||
Transit,
|
||||
/// <summary>Carries environmental-storytelling prose; usually no encounter, often unique decor.</summary>
|
||||
Narrative,
|
||||
/// <summary>Optional reward room with a container slot (sometimes locked).</summary>
|
||||
Loot,
|
||||
/// <summary>The dungeon's set-piece final room. Always one in dungeons that declare it.</summary>
|
||||
Boss,
|
||||
/// <summary>A side room off the critical path — exists for exploration reward.</summary>
|
||||
DeadEnd,
|
||||
}
|
||||
|
||||
internal static class RoomRoleExtensions
|
||||
{
|
||||
public static RoomRole Parse(string raw) => raw switch
|
||||
{
|
||||
"entry" => RoomRole.Entry,
|
||||
"transit" => RoomRole.Transit,
|
||||
"narrative" => RoomRole.Narrative,
|
||||
"loot" => RoomRole.Loot,
|
||||
"boss" => RoomRole.Boss,
|
||||
"dead-end" => RoomRole.DeadEnd,
|
||||
_ => throw new System.ArgumentException($"Unknown room role: '{raw}'"),
|
||||
};
|
||||
|
||||
public static string ToTag(this RoomRole r) => r switch
|
||||
{
|
||||
RoomRole.Entry => "entry",
|
||||
RoomRole.Transit => "transit",
|
||||
RoomRole.Narrative => "narrative",
|
||||
RoomRole.Loot => "loot",
|
||||
RoomRole.Boss => "boss",
|
||||
RoomRole.DeadEnd => "dead-end",
|
||||
_ => "transit",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.World;
|
||||
|
||||
namespace Theriapolis.Core.Dungeons;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M1 — paints the rooms + corridors of a planned dungeon into a
|
||||
/// tactical-tile array. Pure, deterministic given the same plan input.
|
||||
///
|
||||
/// The painter:
|
||||
/// 1. Allocates a <see cref="TacticalTile"/>[w, h] array of the planned
|
||||
/// dungeon size.
|
||||
/// 2. For each room, copies its <see cref="RoomTemplateDef.Grid"/> char
|
||||
/// by char into the array at the room's AABB top-left, mapping each
|
||||
/// char to a <see cref="TacticalSurface"/> + <see cref="TacticalDeco"/>
|
||||
/// pair via <see cref="MapChar"/>.
|
||||
/// 3. For each connection, runs a Manhattan-path corridor between the
|
||||
/// two door tiles, writing <see cref="TacticalSurface.DungeonFloor"/>
|
||||
/// and ensuring the door tiles themselves are walkable
|
||||
/// (<see cref="TacticalDeco.DungeonDoor"/>).
|
||||
///
|
||||
/// The dungeon's surface family (DungeonFloor vs Cave vs MineFloor) is
|
||||
/// chosen by dungeon type so different dungeons feel visually distinct
|
||||
/// even before art lands.
|
||||
/// </summary>
|
||||
internal static class RoomTilePainter
|
||||
{
|
||||
public static TacticalTile[,] Paint(
|
||||
RoomGraphAssembler.Plan plan,
|
||||
IReadOnlyDictionary<string, RoomTemplateDef> templatesById,
|
||||
PoiType dungeonType)
|
||||
{
|
||||
var tiles = new TacticalTile[plan.DungeonW, plan.DungeonH];
|
||||
|
||||
// Default fill: solid wall (so any unpainted gap is automatically
|
||||
// impassable). The painter then carves rooms + corridors out of it.
|
||||
for (int y = 0; y < plan.DungeonH; y++)
|
||||
for (int x = 0; x < plan.DungeonW; x++)
|
||||
{
|
||||
tiles[x, y] = new TacticalTile
|
||||
{
|
||||
Surface = TacticalSurface.Wall,
|
||||
Deco = TacticalDeco.None,
|
||||
Variant = 0,
|
||||
Flags = 0,
|
||||
};
|
||||
}
|
||||
|
||||
TacticalSurface defaultFloor = SurfaceForDungeonType(dungeonType);
|
||||
|
||||
// Paint each room's grid.
|
||||
foreach (var room in plan.Rooms)
|
||||
{
|
||||
if (!templatesById.TryGetValue(room.TemplateId, out var def))
|
||||
continue; // shouldn't happen — ContentLoader validates these
|
||||
for (int gy = 0; gy < def.FootprintHTiles; gy++)
|
||||
{
|
||||
for (int gx = 0; gx < def.FootprintWTiles; gx++)
|
||||
{
|
||||
int dx = room.AabbX + gx;
|
||||
int dy = room.AabbY + gy;
|
||||
if (dx < 0 || dy < 0 || dx >= plan.DungeonW || dy >= plan.DungeonH)
|
||||
continue;
|
||||
char ch = def.Grid[gy][gx];
|
||||
var (surface, deco) = MapChar(ch, defaultFloor);
|
||||
tiles[dx, dy].Surface = surface;
|
||||
tiles[dx, dy].Deco = deco;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Carve corridors between door tiles.
|
||||
foreach (var conn in plan.Connections)
|
||||
{
|
||||
CarveCorridor(tiles, plan, conn, defaultFloor);
|
||||
}
|
||||
|
||||
// Mark the entrance tile so the renderer can highlight it. Stairs
|
||||
// is the canonical "interactable enter / exit" deco.
|
||||
if (plan.EntranceTile.X >= 0 && plan.EntranceTile.X < plan.DungeonW
|
||||
&& plan.EntranceTile.Y >= 0 && plan.EntranceTile.Y < plan.DungeonH)
|
||||
{
|
||||
tiles[plan.EntranceTile.X, plan.EntranceTile.Y].Surface = defaultFloor;
|
||||
tiles[plan.EntranceTile.X, plan.EntranceTile.Y].Deco = TacticalDeco.Stairs;
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private static (TacticalSurface surface, TacticalDeco deco) MapChar(char ch, TacticalSurface defaultFloor) => ch switch
|
||||
{
|
||||
'#' => (TacticalSurface.Wall, TacticalDeco.None),
|
||||
'.' => (defaultFloor, TacticalDeco.None),
|
||||
',' => (TacticalSurface.DungeonRubble, TacticalDeco.None),
|
||||
'D' => (defaultFloor, TacticalDeco.DungeonDoor),
|
||||
'@' => (defaultFloor, TacticalDeco.None), // encounter slot — spawn placed by populator
|
||||
'C' => (defaultFloor, TacticalDeco.Container),
|
||||
'T' => (defaultFloor, TacticalDeco.Trap),
|
||||
'P' => (defaultFloor, TacticalDeco.Pillar),
|
||||
'B' => (defaultFloor, TacticalDeco.Brazier),
|
||||
'M' => (TacticalSurface.DungeonTile, TacticalDeco.None), // mosaic / narrative inlay
|
||||
'S' => (defaultFloor, TacticalDeco.Stairs),
|
||||
' ' => (TacticalSurface.None, TacticalDeco.None),
|
||||
_ => (defaultFloor, TacticalDeco.None), // unknown → walkable floor
|
||||
};
|
||||
|
||||
private static TacticalSurface SurfaceForDungeonType(PoiType type) => type switch
|
||||
{
|
||||
PoiType.AbandonedMine => TacticalSurface.MineFloor,
|
||||
PoiType.NaturalCave => TacticalSurface.Cave,
|
||||
PoiType.CultDen => TacticalSurface.Cave,
|
||||
PoiType.OvergrownSettlement => TacticalSurface.DungeonFloor,
|
||||
PoiType.ImperiumRuin => TacticalSurface.DungeonFloor,
|
||||
_ => TacticalSurface.DungeonFloor,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Manhattan corridor from door A to door B. Picks one of the two L-bends
|
||||
/// deterministically (vertical-first if the connection is "more vertical"
|
||||
/// than horizontal; horizontal-first otherwise) so two seeds don't
|
||||
/// produce different routes through the same plan.
|
||||
/// </summary>
|
||||
private static void CarveCorridor(
|
||||
TacticalTile[,] tiles,
|
||||
RoomGraphAssembler.Plan plan,
|
||||
RoomConnection conn,
|
||||
TacticalSurface defaultFloor)
|
||||
{
|
||||
int x0 = conn.DoorAx, y0 = conn.DoorAy;
|
||||
int x1 = conn.DoorBx, y1 = conn.DoorBy;
|
||||
|
||||
// Ensure the door tiles themselves are walkable.
|
||||
SafeSet(tiles, plan, x0, y0, defaultFloor, TacticalDeco.DungeonDoor);
|
||||
SafeSet(tiles, plan, x1, y1, defaultFloor, TacticalDeco.DungeonDoor);
|
||||
|
||||
// Decide bend axis.
|
||||
int dx = Math.Abs(x1 - x0);
|
||||
int dy = Math.Abs(y1 - y0);
|
||||
bool horizontalFirst = dx >= dy;
|
||||
|
||||
if (horizontalFirst)
|
||||
{
|
||||
int xa = Math.Min(x0, x1);
|
||||
int xb = Math.Max(x0, x1);
|
||||
for (int x = xa; x <= xb; x++)
|
||||
SafeSet(tiles, plan, x, y0, defaultFloor, TacticalDeco.None, preserveDoor: true);
|
||||
int ya = Math.Min(y0, y1);
|
||||
int yb = Math.Max(y0, y1);
|
||||
for (int y = ya; y <= yb; y++)
|
||||
SafeSet(tiles, plan, x1, y, defaultFloor, TacticalDeco.None, preserveDoor: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
int ya = Math.Min(y0, y1);
|
||||
int yb = Math.Max(y0, y1);
|
||||
for (int y = ya; y <= yb; y++)
|
||||
SafeSet(tiles, plan, x0, y, defaultFloor, TacticalDeco.None, preserveDoor: true);
|
||||
int xa = Math.Min(x0, x1);
|
||||
int xb = Math.Max(x0, x1);
|
||||
for (int x = xa; x <= xb; x++)
|
||||
SafeSet(tiles, plan, x, y1, defaultFloor, TacticalDeco.None, preserveDoor: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SafeSet(
|
||||
TacticalTile[,] tiles, RoomGraphAssembler.Plan plan,
|
||||
int x, int y, TacticalSurface surface, TacticalDeco deco, bool preserveDoor = false)
|
||||
{
|
||||
if (x < 0 || y < 0 || x >= plan.DungeonW || y >= plan.DungeonH) return;
|
||||
// Don't bulldoze a room's interior decoration when the corridor
|
||||
// happens to clip through (carry-over from straightline paths
|
||||
// that cross a room edge). The painter already laid the room
|
||||
// first, so corridor only needs to convert Wall/None → Floor.
|
||||
var existing = tiles[x, y];
|
||||
if (existing.Surface != TacticalSurface.Wall
|
||||
&& existing.Surface != TacticalSurface.None
|
||||
&& !(preserveDoor && existing.Deco == TacticalDeco.DungeonDoor))
|
||||
return;
|
||||
tiles[x, y].Surface = surface;
|
||||
if (!preserveDoor || existing.Deco != TacticalDeco.DungeonDoor)
|
||||
tiles[x, y].Deco = deco;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Base actor record. Position is in world-pixel space and is meaningful in
|
||||
/// both the world-map view and the tactical view (see Section 5 of the
|
||||
/// implementation plan — one canonical coordinate system).
|
||||
///
|
||||
/// Phase 5 adds the optional <see cref="Character"/> attachment carrying all
|
||||
/// gameplay state (stats, inventory, HP, conditions). The renderer layer
|
||||
/// stays ignorant of the gameplay layer — Camera2D doesn't need to know HP.
|
||||
/// </summary>
|
||||
public class Actor
|
||||
{
|
||||
/// <summary>Stable id assigned by <see cref="ActorManager"/>.</summary>
|
||||
public int Id { get; init; }
|
||||
|
||||
/// <summary>World-pixel position. Reused at both zoom scales.</summary>
|
||||
public Vec2 Position { get; set; }
|
||||
|
||||
/// <summary>Facing angle in radians (0 = east, π/2 = south).</summary>
|
||||
public float FacingAngleRad { get; set; }
|
||||
|
||||
/// <summary>Continuous-time travel speed on the world map, in world pixels per real second.</summary>
|
||||
public float SpeedWorldPxPerSec { get; set; } = C.PLAYER_TRAVEL_PX_PER_SEC;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5: gameplay state. Null when this actor isn't combat-capable
|
||||
/// (cosmetic NPCs, future-phase additions). PlayerActor and NpcActor
|
||||
/// always carry one.
|
||||
/// </summary>
|
||||
public Character? Character { get; set; }
|
||||
|
||||
/// <summary>Whose side this actor is on. Drives encounter triggering and AI targeting.</summary>
|
||||
public Allegiance Allegiance { get; set; } = Allegiance.Neutral;
|
||||
|
||||
/// <summary>
|
||||
/// True if this actor still has positive HP (or is downed but not dead).
|
||||
/// Subclasses (e.g. <see cref="NpcActor"/>) override to use direct HP
|
||||
/// fields instead of a <see cref="Character"/> reference.
|
||||
/// </summary>
|
||||
public virtual bool IsAlive => Character is null ? true : Character.IsAlive;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
namespace Theriapolis.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Owns live actors. Phase 4 only ever holds the single player actor; this
|
||||
/// class exists so Phase 5/6 can add NPCs without further architectural change.
|
||||
/// </summary>
|
||||
public sealed class ActorManager
|
||||
{
|
||||
private readonly List<Actor> _actors = new();
|
||||
private int _nextId = 1;
|
||||
|
||||
public IReadOnlyList<Actor> All => _actors;
|
||||
|
||||
public PlayerActor? Player { get; private set; }
|
||||
|
||||
/// <summary>Creates the player actor. Idempotent on repeat calls — returns the existing player.</summary>
|
||||
public PlayerActor SpawnPlayer(Util.Vec2 worldPixelPos)
|
||||
{
|
||||
if (Player is not null) return Player;
|
||||
var p = new PlayerActor
|
||||
{
|
||||
Id = _nextId++,
|
||||
Position = worldPixelPos,
|
||||
Allegiance = Rules.Character.Allegiance.Player,
|
||||
};
|
||||
_actors.Add(p);
|
||||
Player = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 overload: spawn the player and attach a freshly-built character
|
||||
/// in one step. The character carries the player's name into the actor.
|
||||
/// </summary>
|
||||
public PlayerActor SpawnPlayer(Util.Vec2 worldPixelPos, Rules.Character.Character character)
|
||||
{
|
||||
var p = SpawnPlayer(worldPixelPos);
|
||||
p.Character = character;
|
||||
// The character record's identity (name) wins over the default actor name.
|
||||
// Other fields (HP, abilities, inventory) live exclusively on the Character.
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore a player actor from a save. Does not allocate a new id — the
|
||||
/// saved id is restored as-is so cross-save references stay stable.
|
||||
/// </summary>
|
||||
public PlayerActor RestorePlayer(PlayerActorState state)
|
||||
{
|
||||
var p = new PlayerActor { Id = state.Id };
|
||||
p.RestoreState(state);
|
||||
_actors.Add(p);
|
||||
Player = p;
|
||||
if (state.Id >= _nextId) _nextId = state.Id + 1;
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M5: spawn an NPC at the given world-pixel position. The caller
|
||||
/// chooses the template (including any DangerZone-driven per-zone selection).
|
||||
/// Returns the spawned actor with a freshly-allocated id.
|
||||
/// </summary>
|
||||
public NpcActor SpawnNpc(
|
||||
Data.NpcTemplateDef template,
|
||||
Util.Vec2 worldPixelPos,
|
||||
Tactical.ChunkCoord? sourceChunk = null,
|
||||
int? sourceSpawnIndex = null)
|
||||
{
|
||||
var npc = new NpcActor(template)
|
||||
{
|
||||
Id = _nextId++,
|
||||
Position = worldPixelPos,
|
||||
SourceChunk = sourceChunk,
|
||||
SourceSpawnIndex = sourceSpawnIndex,
|
||||
};
|
||||
_actors.Add(npc);
|
||||
return npc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 — adopt a pre-constructed NpcActor (e.g. a resident built
|
||||
/// by <see cref="Rules.Combat.ResidentInstantiator"/> with the resident
|
||||
/// template constructor). Assigns an id and registers in the actor list.
|
||||
/// </summary>
|
||||
public NpcActor SpawnNpc(NpcActor pre)
|
||||
{
|
||||
if (pre.Id <= 0)
|
||||
{
|
||||
// Use reflection-free init via a fresh actor copy. NpcActor.Id is
|
||||
// init-only, but the only place we hit this path is the
|
||||
// resident-instantiator which constructs `pre` with Id = -1
|
||||
// expecting us to assign. Build the real one here.
|
||||
NpcActor adopted = pre.Resident is not null
|
||||
? new NpcActor(pre.Resident)
|
||||
{
|
||||
Id = _nextId++,
|
||||
Position = pre.Position,
|
||||
SourceChunk = pre.SourceChunk,
|
||||
SourceSpawnIndex = pre.SourceSpawnIndex,
|
||||
RoleTag = pre.RoleTag,
|
||||
HomeSettlementId = pre.HomeSettlementId,
|
||||
}
|
||||
: new NpcActor(pre.Template!)
|
||||
{
|
||||
Id = _nextId++,
|
||||
Position = pre.Position,
|
||||
SourceChunk = pre.SourceChunk,
|
||||
SourceSpawnIndex = pre.SourceSpawnIndex,
|
||||
RoleTag = pre.RoleTag,
|
||||
HomeSettlementId = pre.HomeSettlementId,
|
||||
};
|
||||
_actors.Add(adopted);
|
||||
return adopted;
|
||||
}
|
||||
_actors.Add(pre);
|
||||
if (pre.Id >= _nextId) _nextId = pre.Id + 1;
|
||||
return pre;
|
||||
}
|
||||
|
||||
/// <summary>Remove an actor by id. Returns true if it was found.</summary>
|
||||
public bool RemoveActor(int id)
|
||||
{
|
||||
for (int i = 0; i < _actors.Count; i++)
|
||||
{
|
||||
if (_actors[i].Id == id) { _actors.RemoveAt(i); return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Returns every NPC currently spawned.</summary>
|
||||
public IEnumerable<NpcActor> Npcs
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (var a in _actors) if (a is NpcActor npc) yield return npc;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Find a live NPC originating from a specific chunk + spawn index.</summary>
|
||||
public NpcActor? FindNpcBySource(Tactical.ChunkCoord chunk, int spawnIndex)
|
||||
{
|
||||
foreach (var a in _actors)
|
||||
if (a is NpcActor npc &&
|
||||
npc.SourceChunk.HasValue && npc.SourceChunk.Value.Equals(chunk) &&
|
||||
npc.SourceSpawnIndex == spawnIndex)
|
||||
return npc;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Theriapolis.Core.Rules.Combat;
|
||||
|
||||
namespace Theriapolis.Core.Entities.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only view of the world the AI behavior consults during a turn.
|
||||
/// Wraps the live <see cref="Encounter"/> + a sight predicate so behaviors
|
||||
/// stay testable in isolation (M5 unit tests pass an always-clear predicate;
|
||||
/// the live game wires in the tactical-tile sampler).
|
||||
///
|
||||
/// Behaviors call into the resolver to actually mutate the encounter; this
|
||||
/// context is purely for reading the situation.
|
||||
/// </summary>
|
||||
public sealed class AiContext
|
||||
{
|
||||
public Encounter Encounter { get; }
|
||||
|
||||
/// <summary>Returns true if the tile at (tx, ty) blocks line-of-sight.</summary>
|
||||
public System.Func<int, int, bool> IsLosBlocked { get; }
|
||||
|
||||
public AiContext(Encounter encounter, System.Func<int, int, bool>? isLosBlocked = null)
|
||||
{
|
||||
Encounter = encounter;
|
||||
IsLosBlocked = isLosBlocked ?? LineOfSight.AlwaysClear;
|
||||
}
|
||||
|
||||
/// <summary>Find the closest hostile combatant to the given actor, or null if none.</summary>
|
||||
public Combatant? FindClosestHostile(Combatant self)
|
||||
{
|
||||
Combatant? best = null;
|
||||
int bestDist = int.MaxValue;
|
||||
foreach (var c in Encounter.Participants)
|
||||
{
|
||||
if (c.Id == self.Id) continue;
|
||||
if (c.IsDown) continue;
|
||||
if (!IsHostileTo(self.Allegiance, c.Allegiance)) continue;
|
||||
int d = ReachAndCover.EdgeToEdgeChebyshev(self, c);
|
||||
if (d < bestDist) { best = c; bestDist = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M1 — find the closest *ally* to the given actor (excludes
|
||||
/// self). Allies share the player-side allegiance: Player or Allied.
|
||||
/// Returns null when none qualify (which is the typical M1 case — the
|
||||
/// player is alone — and disables ally-targeted features cleanly).
|
||||
/// </summary>
|
||||
public Combatant? FindClosestAlly(Combatant self)
|
||||
{
|
||||
Combatant? best = null;
|
||||
int bestDist = int.MaxValue;
|
||||
bool selfPlayerSide = self.Allegiance == Rules.Character.Allegiance.Player
|
||||
|| self.Allegiance == Rules.Character.Allegiance.Allied;
|
||||
if (!selfPlayerSide) return null;
|
||||
foreach (var c in Encounter.Participants)
|
||||
{
|
||||
if (c.Id == self.Id) continue;
|
||||
if (c.IsDown) continue;
|
||||
bool cPlayerSide = c.Allegiance == Rules.Character.Allegiance.Player
|
||||
|| c.Allegiance == Rules.Character.Allegiance.Allied;
|
||||
if (!cPlayerSide) continue;
|
||||
int d = ReachAndCover.EdgeToEdgeChebyshev(self, c);
|
||||
if (d < bestDist) { best = c; bestDist = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M1 — find the lowest-HP friendly target (self or any
|
||||
/// ally), preferring the most damaged. Returns null only when every
|
||||
/// friendly is at full HP. Used as the auto-target for healing
|
||||
/// features when the player presses the heal hotkey without picking
|
||||
/// explicitly.
|
||||
/// </summary>
|
||||
public Combatant? FindMostDamagedFriendly(Combatant self)
|
||||
{
|
||||
Combatant? best = null;
|
||||
int bestDeficit = -1;
|
||||
bool selfPlayerSide = self.Allegiance == Rules.Character.Allegiance.Player
|
||||
|| self.Allegiance == Rules.Character.Allegiance.Allied;
|
||||
if (!selfPlayerSide) return null;
|
||||
foreach (var c in Encounter.Participants)
|
||||
{
|
||||
if (c.IsDown) continue;
|
||||
bool cPlayerSide = c.Allegiance == Rules.Character.Allegiance.Player
|
||||
|| c.Allegiance == Rules.Character.Allegiance.Allied;
|
||||
if (!cPlayerSide) continue;
|
||||
int deficit = c.MaxHp - c.CurrentHp;
|
||||
if (deficit > bestDeficit) { best = c; bestDeficit = deficit; }
|
||||
}
|
||||
return bestDeficit > 0 ? best : null;
|
||||
}
|
||||
|
||||
/// <summary>True if the two allegiances should target each other in combat.</summary>
|
||||
public static bool IsHostileTo(
|
||||
Rules.Character.Allegiance a,
|
||||
Rules.Character.Allegiance b)
|
||||
{
|
||||
bool aIsPlayerSide = a == Rules.Character.Allegiance.Player || a == Rules.Character.Allegiance.Allied;
|
||||
bool bIsPlayerSide = b == Rules.Character.Allegiance.Player || b == Rules.Character.Allegiance.Allied;
|
||||
if (aIsPlayerSide && b == Rules.Character.Allegiance.Hostile) return true;
|
||||
if (bIsPlayerSide && a == Rules.Character.Allegiance.Hostile) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Theriapolis.Core.Rules.Combat;
|
||||
|
||||
namespace Theriapolis.Core.Entities.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// "Move toward target, attack when in reach" — the baseline aggressive
|
||||
/// melee behavior. Used by Brigand* and Patrol templates that aren't
|
||||
/// scripted to flee.
|
||||
/// </summary>
|
||||
public sealed class BrigandBehavior : INpcBehavior
|
||||
{
|
||||
public void TakeTurn(Combatant self, AiContext ctx)
|
||||
{
|
||||
if (self.IsDown) return;
|
||||
var target = ctx.FindClosestHostile(self);
|
||||
if (target is null) return;
|
||||
var attack = self.AttackOptions[0];
|
||||
|
||||
// Move budget: 5 ft. = 1 tactical tile per d20 standard.
|
||||
int tiles = ctx.Encounter.CurrentTurn.RemainingMovementFt / 5;
|
||||
while (!ReachAndCover.IsInReach(self, target, attack) && tiles > 0)
|
||||
{
|
||||
var next = ReachAndCover.StepToward(self.Position, target.Position);
|
||||
if (next.X == self.Position.X && next.Y == self.Position.Y) break;
|
||||
self.Position = next;
|
||||
ctx.Encounter.AppendLog(CombatLogEntry.Kind.Move,
|
||||
$"{self.Name} moves to ({(int)next.X},{(int)next.Y}).");
|
||||
tiles--;
|
||||
}
|
||||
int consumedTiles = (ctx.Encounter.CurrentTurn.RemainingMovementFt / 5) - tiles;
|
||||
ctx.Encounter.CurrentTurn.ConsumeMovement(consumedTiles * 5);
|
||||
|
||||
if (!ReachAndCover.IsInReach(self, target, attack)) return;
|
||||
Resolver.AttemptAttack(ctx.Encounter, self, target, attack);
|
||||
ctx.Encounter.CurrentTurn.ConsumeAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Theriapolis.Core.Rules.Combat;
|
||||
|
||||
namespace Theriapolis.Core.Entities.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// One NPC behavior — what does this NPC do on its turn? Behaviors are
|
||||
/// dispatched by id (the template's <c>behavior</c> field) via
|
||||
/// <see cref="BehaviorRegistry"/>. Each behavior must produce its turn's
|
||||
/// actions within bounded time — no recursion, no while-true loops; if no
|
||||
/// valid action is available, end the turn.
|
||||
/// </summary>
|
||||
public interface INpcBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// Take one turn for <paramref name="self"/>. Behaviors call into
|
||||
/// <see cref="Resolver"/> to mutate the encounter, then return — the
|
||||
/// caller (typically <see cref="Encounter"/>'s turn pump) calls
|
||||
/// <see cref="Encounter.EndTurn"/> after this returns.
|
||||
/// </summary>
|
||||
void TakeTurn(Combatant self, AiContext ctx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps behavior ids (the strings in <c>npc_templates.json</c>'s
|
||||
/// <c>behavior</c> field) to their <see cref="INpcBehavior"/>
|
||||
/// implementation. Phase 5 M5 ships three: <c>brigand</c>,
|
||||
/// <c>wild_animal</c>, <c>poi_guard</c>. Unknown ids fall back to
|
||||
/// <c>brigand</c> with a debug log.
|
||||
/// </summary>
|
||||
public static class BehaviorRegistry
|
||||
{
|
||||
private static readonly System.Collections.Generic.Dictionary<string, INpcBehavior> _impls
|
||||
= new(System.StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["brigand"] = new BrigandBehavior(),
|
||||
["wild_animal"] = new WildAnimalBehavior(),
|
||||
["poi_guard"] = new PoiGuardBehavior(),
|
||||
["patrol"] = new BrigandBehavior(), // patrol shares brigand combat behavior
|
||||
};
|
||||
|
||||
public static INpcBehavior For(string id)
|
||||
{
|
||||
return _impls.TryGetValue(id, out var b) ? b : _impls["brigand"];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Theriapolis.Core.Entities.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Defends a point of interest. Phase 5 M5 ships this as Brigand-equivalent
|
||||
/// combat behavior — patrolling around a home position is M6 territory once
|
||||
/// out-of-combat tactical movement is implemented (currently NPCs only act
|
||||
/// during encounters).
|
||||
/// </summary>
|
||||
public sealed class PoiGuardBehavior : INpcBehavior
|
||||
{
|
||||
private readonly BrigandBehavior _melee = new();
|
||||
|
||||
public void TakeTurn(Rules.Combat.Combatant self, AiContext ctx)
|
||||
{
|
||||
// Same combat logic as Brigand. The "patrol around home" behavior
|
||||
// outside of combat hooks in at M6 — for now PoiGuards stand still
|
||||
// until engaged.
|
||||
_melee.TakeTurn(self, ctx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Theriapolis.Core.Rules.Combat;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Entities.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Like <see cref="BrigandBehavior"/>, but flees when reduced below
|
||||
/// <c>FLEE_HP_FRACTION</c> of max HP — wild animals don't have honor.
|
||||
/// </summary>
|
||||
public sealed class WildAnimalBehavior : INpcBehavior
|
||||
{
|
||||
public const float FLEE_HP_FRACTION = 0.25f;
|
||||
|
||||
public void TakeTurn(Combatant self, AiContext ctx)
|
||||
{
|
||||
if (self.IsDown) return;
|
||||
var target = ctx.FindClosestHostile(self);
|
||||
if (target is null) return;
|
||||
|
||||
// Flee at low HP — move directly away, don't attack.
|
||||
if (self.CurrentHp <= self.MaxHp * FLEE_HP_FRACTION)
|
||||
{
|
||||
int tiles = ctx.Encounter.CurrentTurn.RemainingMovementFt / 5;
|
||||
for (int i = 0; i < tiles; i++)
|
||||
{
|
||||
var away = StepAwayFrom(self.Position, target.Position);
|
||||
if ((int)away.X == (int)self.Position.X && (int)away.Y == (int)self.Position.Y) break;
|
||||
self.Position = away;
|
||||
ctx.Encounter.AppendLog(CombatLogEntry.Kind.Move,
|
||||
$"{self.Name} flees to ({(int)away.X},{(int)away.Y}).");
|
||||
}
|
||||
ctx.Encounter.CurrentTurn.ConsumeMovement(tiles * 5);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, identical to Brigand: close + attack.
|
||||
var attack = self.AttackOptions[0];
|
||||
int tilesAvail = ctx.Encounter.CurrentTurn.RemainingMovementFt / 5;
|
||||
while (!ReachAndCover.IsInReach(self, target, attack) && tilesAvail > 0)
|
||||
{
|
||||
var next = ReachAndCover.StepToward(self.Position, target.Position);
|
||||
if ((int)next.X == (int)self.Position.X && (int)next.Y == (int)self.Position.Y) break;
|
||||
self.Position = next;
|
||||
ctx.Encounter.AppendLog(CombatLogEntry.Kind.Move,
|
||||
$"{self.Name} moves to ({(int)next.X},{(int)next.Y}).");
|
||||
tilesAvail--;
|
||||
}
|
||||
int consumedTiles = (ctx.Encounter.CurrentTurn.RemainingMovementFt / 5) - tilesAvail;
|
||||
ctx.Encounter.CurrentTurn.ConsumeMovement(consumedTiles * 5);
|
||||
|
||||
if (!ReachAndCover.IsInReach(self, target, attack)) return;
|
||||
Resolver.AttemptAttack(ctx.Encounter, self, target, attack);
|
||||
ctx.Encounter.CurrentTurn.ConsumeAction();
|
||||
}
|
||||
|
||||
private static Vec2 StepAwayFrom(Vec2 from, Vec2 menace)
|
||||
{
|
||||
// Reverse of StepToward — increment by sign of (from - menace) so we
|
||||
// move away from the menace.
|
||||
int dx = System.Math.Sign(from.X - menace.X);
|
||||
int dy = System.Math.Sign(from.Y - menace.Y);
|
||||
if (dx == 0 && dy == 0) dx = 1; // pick a direction if standing on top
|
||||
return new Vec2((int)from.X + dx, (int)from.Y + dy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M5 NPC actor. Distinct from <see cref="PlayerActor"/>: stat-block
|
||||
/// driven (no <see cref="Rules.Character.Character"/>), HP/AC/attacks read
|
||||
/// directly from the source <see cref="NpcTemplateDef"/>. The
|
||||
/// <see cref="Combatant"/> wrapper is built from this on encounter start.
|
||||
///
|
||||
/// AI behavior is dispatched by id (the template's <c>behavior</c> field)
|
||||
/// — the ActorManager doesn't tick behaviors itself; combat does, and the
|
||||
/// ChunkStreamer-driven spawn/despawn lifecycle keeps the live actor list
|
||||
/// in sync with the player's tactical window.
|
||||
/// </summary>
|
||||
public sealed class NpcActor : Actor
|
||||
{
|
||||
/// <summary>
|
||||
/// Phase 5 hostile/wild template. Mutually exclusive with <see cref="Resident"/>.
|
||||
/// One of the two must be non-null.
|
||||
/// </summary>
|
||||
public NpcTemplateDef? Template { get; }
|
||||
|
||||
/// <summary>Phase 6 M1 friendly/neutral resident template.</summary>
|
||||
public ResidentTemplateDef? Resident { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 — display name (e.g. "Mara Threadwell"). For Phase-5 NPCs
|
||||
/// falls back to <see cref="NpcTemplateDef.Name"/>; for residents uses
|
||||
/// <see cref="ResidentTemplateDef.Name"/>.
|
||||
/// </summary>
|
||||
public string DisplayName => Resident?.Name ?? Template?.Name ?? "NPC";
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 — role tag (anchor-qualified for named NPCs:
|
||||
/// "millhaven.innkeeper", or generic: "shopkeeper"). Empty for hostile
|
||||
/// NPCs that don't carry a settlement role.
|
||||
/// </summary>
|
||||
public string RoleTag { get; init; } = "";
|
||||
|
||||
/// <summary>Phase 6 M1 — dialogue tree id (matches dialogues/*.json). Empty → InteractionScreen falls back to placeholder.</summary>
|
||||
public string DialogueId { get; init; } = "";
|
||||
|
||||
/// <summary>Phase 6 M1 — bias profile id used in disposition formula.</summary>
|
||||
public string BiasProfileId { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 / M5 — faction affiliation id. Set automatically from
|
||||
/// <see cref="Data.NpcTemplateDef.Faction"/> or
|
||||
/// <see cref="Data.ResidentTemplateDef.Faction"/> at construction.
|
||||
/// Can also be set directly via init for testing / scripting.
|
||||
/// </summary>
|
||||
public string FactionId { get; init; } = "";
|
||||
|
||||
/// <summary>Current HP. Mutated by combat; written back from <see cref="Rules.Combat.Combatant"/> when an encounter ends.</summary>
|
||||
public int CurrentHp { get; set; }
|
||||
|
||||
/// <summary>HP at full. From whichever template populated this actor.</summary>
|
||||
public int MaxHp { get; }
|
||||
|
||||
/// <summary>The chunk this NPC was spawned from + its index in <c>chunk.Spawns</c>. Null when spawned outside the chunk system.</summary>
|
||||
public Tactical.ChunkCoord? SourceChunk { get; init; }
|
||||
public int? SourceSpawnIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M5 — settlement this NPC belongs to. Set by
|
||||
/// <see cref="Rules.Combat.ResidentInstantiator"/> at spawn time when
|
||||
/// the NPC was placed inside a building footprint. Drives
|
||||
/// <see cref="Rules.Reputation.RepPropagation"/> when computing the
|
||||
/// faction standing this NPC perceives.
|
||||
/// </summary>
|
||||
public int? HomeSettlementId { get; init; }
|
||||
|
||||
/// <summary>Per-NPC AI scratchpad. Behaviors read/write here between turns.</summary>
|
||||
public AiState Ai { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M6 — runtime scent-tag flags. Set by combat events
|
||||
/// (<see cref="HasRecentlyKilled"/> on melee kill, etc.) and
|
||||
/// surfaced through <see cref="ComputeScentTags"/>.
|
||||
///
|
||||
/// Faction-derived tags are NOT stored here — they're computed from
|
||||
/// <see cref="FactionId"/> on demand. Only flags that result from
|
||||
/// runtime activity (kills, fleeing, low HP) live as state.
|
||||
/// </summary>
|
||||
public bool HasRecentlyKilled { get; set; }
|
||||
public bool CarriesContrabandFlag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M7 — sticky aggro flag set by
|
||||
/// <see cref="Rules.Reputation.BetrayalCascade"/> on a betrayed
|
||||
/// guard / patrol NPC. Once set, the NPC attacks on sight regardless
|
||||
/// of faction-standing recovery — they remember.
|
||||
///
|
||||
/// Flag survives chunk despawn/respawn for *named* NPCs (their
|
||||
/// PersonalDisposition.Memory.betrayed_me tag drives re-application
|
||||
/// at re-instantiation). Generic respawning NPCs lose it on chunk
|
||||
/// re-stream — they're literally a fresh template-rolled NPC, the
|
||||
/// betrayal followed the role tag, not the entity.
|
||||
/// </summary>
|
||||
public bool PermanentAggroAfterBetrayal { get; set; }
|
||||
|
||||
/// <summary>Behavior id used by combat AI dispatch. Residents return "resident" (M1 stub — they don't move).</summary>
|
||||
public string BehaviorId => Template?.Behavior ?? "resident";
|
||||
|
||||
public override bool IsAlive => CurrentHp > 0;
|
||||
|
||||
public NpcActor(NpcTemplateDef template)
|
||||
{
|
||||
Template = template ?? throw new System.ArgumentNullException(nameof(template));
|
||||
Resident = null;
|
||||
CurrentHp = template.Hp;
|
||||
MaxHp = template.Hp;
|
||||
Allegiance = Rules.Character.AllegianceExtensions.FromJson(template.DefaultAllegiance);
|
||||
// Phase 6 M5 — pick up the template's faction id so propagation can
|
||||
// re-classify (e.g. militia_patrol → covenant_enforcers).
|
||||
FactionId = template.Faction;
|
||||
}
|
||||
|
||||
/// <summary>Phase 6 M1 — resident-template constructor.</summary>
|
||||
public NpcActor(ResidentTemplateDef resident)
|
||||
{
|
||||
Template = null;
|
||||
Resident = resident ?? throw new System.ArgumentNullException(nameof(resident));
|
||||
CurrentHp = resident.Hp;
|
||||
MaxHp = resident.Hp;
|
||||
RoleTag = resident.RoleTag;
|
||||
DialogueId = resident.Dialogue;
|
||||
BiasProfileId = resident.BiasProfile;
|
||||
FactionId = resident.Faction;
|
||||
Allegiance = Rules.Character.AllegianceExtensions.FromJson(resident.DefaultAllegiance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M6 — compute the ordered scent-tag list for a Scent-Broker
|
||||
/// reading this NPC. Higher-priority tags sort first; ties broken by
|
||||
/// enum order. Returns at most <paramref name="maxCount"/> entries.
|
||||
///
|
||||
/// Standard tiers:
|
||||
/// - Scent Literacy (level 1): maxCount = 1 — the headline read.
|
||||
/// - Scent Mastery (master_nose, level 11): maxCount = 3 — fuller picture.
|
||||
///
|
||||
/// Tag derivation:
|
||||
/// - <see cref="FactionId"/> resolves to a single faction-affiliation
|
||||
/// tag (priority 1–8 by enum order).
|
||||
/// - <see cref="HasRecentlyKilled"/> → <see cref="ScentTag.RecentlyKilled"/>.
|
||||
/// - HP < 25% (or low-HP fleeing) → <see cref="ScentTag.Frightened"/>.
|
||||
/// - HP < 50% → <see cref="ScentTag.Wounded"/>.
|
||||
/// - <see cref="CarriesContrabandFlag"/> → <see cref="ScentTag.CarriesContraband"/>.
|
||||
/// </summary>
|
||||
public List<ScentTag> ComputeScentTags(int maxCount = 1)
|
||||
{
|
||||
if (maxCount <= 0) return new List<ScentTag>();
|
||||
var list = new List<ScentTag>();
|
||||
|
||||
// Faction-derived (priority 1–8). At most one — an NPC carries one
|
||||
// faction's chemistry strongly.
|
||||
var factionTag = ScentTagExtensions.FromFactionId(FactionId);
|
||||
if (factionTag != ScentTag.None) list.Add(factionTag);
|
||||
|
||||
// Runtime-derived. Order matters — most informative first.
|
||||
if (HasRecentlyKilled) list.Add(ScentTag.RecentlyKilled);
|
||||
|
||||
// Distress markers — cheaper of the two when triggered.
|
||||
float hpFraction = MaxHp > 0 ? (float)CurrentHp / MaxHp : 1f;
|
||||
if (hpFraction < 0.25f && CurrentHp > 0)
|
||||
list.Add(ScentTag.Frightened);
|
||||
else if (hpFraction < 0.50f && CurrentHp > 0)
|
||||
list.Add(ScentTag.Wounded);
|
||||
|
||||
if (CarriesContrabandFlag) list.Add(ScentTag.CarriesContraband);
|
||||
|
||||
// Truncate to the cap.
|
||||
if (list.Count > maxCount) list.RemoveRange(maxCount, list.Count - maxCount);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mutable per-NPC AI state. Behaviors read/write between turns.</summary>
|
||||
public sealed class AiState
|
||||
{
|
||||
/// <summary>Last position where this NPC saw a hostile target. Used to chase after losing line-of-sight.</summary>
|
||||
public Vec2? LastSeenTargetPos { get; set; }
|
||||
|
||||
/// <summary>Turns since LastSeenTargetPos was updated. After N turns, the NPC gives up and returns to home.</summary>
|
||||
public int TurnsSinceLastSeen { get; set; }
|
||||
|
||||
/// <summary>"Home" position the NPC drifts back to (PoiGuard patrol anchor). Null = no home, just stand still.</summary>
|
||||
public Vec2? HomePos { get; set; }
|
||||
|
||||
/// <summary>Currently engaged (in combat). Cleared on encounter end.</summary>
|
||||
public bool InCombat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Player actor. Phase 4 keeps the field set deliberately small — just enough
|
||||
/// to position, save, and reload a single human-controlled character. Stats,
|
||||
/// inventory, and class data are added in Phase 5.
|
||||
/// </summary>
|
||||
public sealed class PlayerActor : Actor
|
||||
{
|
||||
public string Name { get; set; } = "Wanderer";
|
||||
|
||||
/// <summary>Highest settlement tier the player has actually visited (1 = capital).</summary>
|
||||
public int HighestTierReached { get; set; } = 5;
|
||||
|
||||
/// <summary>Set of discovered settlement / PoI ids. Drives slot-picker labels and Phase 7+ map UI.</summary>
|
||||
public HashSet<int> DiscoveredPoiIds { get; } = new();
|
||||
|
||||
/// <summary>Snapshot used by SaveCodec; mutated by RestoreState.</summary>
|
||||
public PlayerActorState CaptureState() => new()
|
||||
{
|
||||
Id = Id,
|
||||
Name = Name,
|
||||
PositionX = Position.X,
|
||||
PositionY = Position.Y,
|
||||
FacingAngleRad = FacingAngleRad,
|
||||
SpeedWorldPxPerSec = SpeedWorldPxPerSec,
|
||||
HighestTierReached = HighestTierReached,
|
||||
DiscoveredPoiIds = DiscoveredPoiIds.ToArray(),
|
||||
};
|
||||
|
||||
public void RestoreState(PlayerActorState s)
|
||||
{
|
||||
Name = s.Name;
|
||||
Position = new Vec2(s.PositionX, s.PositionY);
|
||||
FacingAngleRad = s.FacingAngleRad;
|
||||
SpeedWorldPxPerSec = s.SpeedWorldPxPerSec;
|
||||
HighestTierReached = s.HighestTierReached;
|
||||
DiscoveredPoiIds.Clear();
|
||||
foreach (int id in s.DiscoveredPoiIds) DiscoveredPoiIds.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plain serializable snapshot of a <see cref="PlayerActor"/>.
|
||||
/// Kept as a struct of primitive fields so the persistence layer doesn't need
|
||||
/// MessagePack attributes on the live object — keeps Core dependency-free for
|
||||
/// modules that don't yet care about saves.
|
||||
/// </summary>
|
||||
public sealed class PlayerActorState
|
||||
{
|
||||
public int Id;
|
||||
public string Name = "";
|
||||
public float PositionX;
|
||||
public float PositionY;
|
||||
public float FacingAngleRad;
|
||||
public float SpeedWorldPxPerSec;
|
||||
public int HighestTierReached;
|
||||
public int[] DiscoveredPoiIds = Array.Empty<int>();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace Theriapolis.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M6 — bounded vocabulary of "scent reads" surfaced by
|
||||
/// Scent-Broker abilities (Scent Literacy at L1, Scent Mastery /
|
||||
/// <c>master_nose</c> at L11). Each tag is a short, in-fiction
|
||||
/// observation a Scent-Broker can pick out of an NPC's scent profile —
|
||||
/// recent activity, faction affiliation, distress markers.
|
||||
///
|
||||
/// Per the Phase 6.5 plan §3.2: bounded enum on purpose. Phase 8's full
|
||||
/// scent-propagation simulation can extend the vocabulary; Phase 6.5
|
||||
/// keeps the set small and the population path simple (auto-derived from
|
||||
/// faction id + a small set of runtime triggers — see
|
||||
/// <see cref="NpcActor.ComputeScentTags"/>).
|
||||
///
|
||||
/// Display priority is the enum order: lower-numbered tags win when
|
||||
/// truncating to a single read for L1 Scent Literacy. Faction-affiliation
|
||||
/// tags lead because they're the most narratively meaningful (Lacroix
|
||||
/// reads as "Maw-affiliated" before "recently killed", because the latter
|
||||
/// is generic brigand flavour).
|
||||
/// </summary>
|
||||
public enum ScentTag : byte
|
||||
{
|
||||
None = 0,
|
||||
|
||||
// ── Faction-affiliation reads (priority 1–8) ─────────────────────────
|
||||
/// <summary>The NPC carries the scent of Maw chemistry / contact.</summary>
|
||||
MawAffiliated = 1,
|
||||
/// <summary>Inheritor pheromone-marker / ration scent.</summary>
|
||||
InheritorAffiliated = 2,
|
||||
/// <summary>Thorn Council chemical signatures.</summary>
|
||||
ThornCouncilAffiliated = 3,
|
||||
/// <summary>Covenant Enforcer protocol scent (uniform launderings, sigil oils).</summary>
|
||||
CovenantEnforcerAffiliated = 4,
|
||||
/// <summary>Hybrid Underground safe-house scent traces.</summary>
|
||||
HybridUndergroundAffiliated = 5,
|
||||
/// <summary>Unsheathed (hybrid activist) chemical markers.</summary>
|
||||
UnsheathedAffiliated = 6,
|
||||
/// <summary>Merchant-guild handshake oils — well-traveled, much-greeted.</summary>
|
||||
MerchantAffiliated = 7,
|
||||
|
||||
// ── Runtime-derived reads (priority 16+) ─────────────────────────────
|
||||
/// <summary>Has killed something recently (within ~1 hour). Set on melee kill.</summary>
|
||||
RecentlyKilled = 16,
|
||||
/// <summary>Carries combat-distress markers — fleeing or near-death.</summary>
|
||||
Frightened = 17,
|
||||
/// <summary>Carries contraband (pheromone vials, deep-cover masks, faction sigils).</summary>
|
||||
CarriesContraband = 18,
|
||||
/// <summary>Wounded — current HP < 50%.</summary>
|
||||
Wounded = 19,
|
||||
}
|
||||
|
||||
public static class ScentTagExtensions
|
||||
{
|
||||
/// <summary>Human-readable label for the InteractionScreen overlay.</summary>
|
||||
public static string DisplayName(this ScentTag tag) => tag switch
|
||||
{
|
||||
ScentTag.MawAffiliated => "Maw-affiliated",
|
||||
ScentTag.InheritorAffiliated => "Inheritor-affiliated",
|
||||
ScentTag.ThornCouncilAffiliated => "Thorn Council-affiliated",
|
||||
ScentTag.CovenantEnforcerAffiliated => "Covenant Enforcer-affiliated",
|
||||
ScentTag.HybridUndergroundAffiliated => "Hybrid Underground-affiliated",
|
||||
ScentTag.UnsheathedAffiliated => "Unsheathed-affiliated",
|
||||
ScentTag.MerchantAffiliated => "Merchant-affiliated",
|
||||
ScentTag.RecentlyKilled => "Recently killed",
|
||||
ScentTag.Frightened => "Frightened",
|
||||
ScentTag.CarriesContraband => "Carries contraband",
|
||||
ScentTag.Wounded => "Wounded",
|
||||
_ => "—",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Map a faction id to the corresponding affiliation tag. Returns
|
||||
/// <see cref="ScentTag.None"/> for empty / unknown faction ids.
|
||||
/// </summary>
|
||||
public static ScentTag FromFactionId(string factionId) => factionId?.ToLowerInvariant() switch
|
||||
{
|
||||
"maw" => ScentTag.MawAffiliated,
|
||||
"inheritors" => ScentTag.InheritorAffiliated,
|
||||
"thorn_council" => ScentTag.ThornCouncilAffiliated,
|
||||
"covenant_enforcers" => ScentTag.CovenantEnforcerAffiliated,
|
||||
"hybrid_underground" => ScentTag.HybridUndergroundAffiliated,
|
||||
"unsheathed" => ScentTag.UnsheathedAffiliated,
|
||||
"merchant_guilds" => ScentTag.MerchantAffiliated,
|
||||
_ => ScentTag.None,
|
||||
};
|
||||
|
||||
/// <summary>True if this tag carries narrative weight — e.g. faction reveals.</summary>
|
||||
public static bool IsNarrative(this ScentTag tag) =>
|
||||
tag != ScentTag.None && (byte)tag < 16;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Core.World;
|
||||
|
||||
namespace Theriapolis.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Plans a continuous-time path between two world tiles. The result is a
|
||||
/// list of world-pixel waypoints (tile centers); the controller animates
|
||||
/// the player along it at <see cref="C.PLAYER_TRAVEL_PX_PER_SEC"/>.
|
||||
///
|
||||
/// Cost function:
|
||||
/// • Ocean tiles (Biome == Ocean) are impassable.
|
||||
/// • Mountain tiles cost 4× a grassland tile (slow but not blocked).
|
||||
/// • Tiles carrying a road get a strong discount (matches the ROAD_SPEED_MULT idea).
|
||||
/// • Diagonal moves are √2 like everywhere else in the codebase.
|
||||
///
|
||||
/// The clock-advance amount is computed afterwards by walking the path
|
||||
/// and summing per-segment travel time — see <see cref="EstimateSecondsForLeg"/>.
|
||||
/// </summary>
|
||||
public sealed class WorldTravelPlanner
|
||||
{
|
||||
private readonly AStarPathfinder _astar = new();
|
||||
private readonly WorldState _world;
|
||||
|
||||
public WorldTravelPlanner(WorldState world) { _world = world; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns null if no path exists. Returned waypoints are tile coordinates,
|
||||
/// not world-pixel coordinates — convert with <see cref="TileCenterToWorldPixel"/>.
|
||||
/// </summary>
|
||||
public List<(int X, int Y)>? PlanTilePath(int sx, int sy, int gx, int gy)
|
||||
{
|
||||
if (!IsWalkable(sx, sy)) return null;
|
||||
if (!IsWalkable(gx, gy)) return null;
|
||||
return _astar.FindPath(sx, sy, gx, gy, CostFn);
|
||||
}
|
||||
|
||||
private float CostFn(int fx, int fy, int tx, int ty, byte _)
|
||||
{
|
||||
if (!IsWalkable(tx, ty)) return float.PositiveInfinity;
|
||||
|
||||
ref var t = ref _world.TileAt(tx, ty);
|
||||
float baseCost = TerrainCost(t);
|
||||
// Strong incentive to follow roads — matches the EXISTING_ROAD_COST
|
||||
// worldgen philosophy. Pure additive (not multiplicative) so it
|
||||
// never goes negative and A* admissibility holds.
|
||||
if ((t.Features & FeatureFlags.HasRoad) != 0) baseCost *= 0.25f;
|
||||
return baseCost;
|
||||
}
|
||||
|
||||
private bool IsWalkable(int x, int y)
|
||||
{
|
||||
if ((uint)x >= C.WORLD_WIDTH_TILES) return false;
|
||||
if ((uint)y >= C.WORLD_HEIGHT_TILES) return false;
|
||||
ref var t = ref _world.TileAt(x, y);
|
||||
return t.Biome != BiomeId.Ocean;
|
||||
}
|
||||
|
||||
private static float TerrainCost(in WorldTile t) => t.Biome switch
|
||||
{
|
||||
BiomeId.MountainAlpine => 4.0f,
|
||||
BiomeId.MountainForested => 3.0f,
|
||||
BiomeId.Wetland => 2.5f,
|
||||
BiomeId.Foothills => 1.8f,
|
||||
BiomeId.Boreal => 1.6f,
|
||||
BiomeId.SubtropicalForest => 1.6f,
|
||||
BiomeId.TemperateDeciduous => 1.5f,
|
||||
BiomeId.Tundra => 1.5f,
|
||||
BiomeId.DesertCold => 1.4f,
|
||||
BiomeId.Mangrove => 1.4f,
|
||||
BiomeId.MarshEdge => 1.4f,
|
||||
BiomeId.Scrubland => 1.2f,
|
||||
BiomeId.ForestEdge => 1.3f,
|
||||
BiomeId.Cliff => 3.0f,
|
||||
BiomeId.TemperateGrassland => 1.0f,
|
||||
BiomeId.RiverValley => 1.0f,
|
||||
BiomeId.Coastal => 1.0f,
|
||||
BiomeId.Beach => 1.0f,
|
||||
BiomeId.TidalFlat => 1.5f,
|
||||
_ => 1.2f,
|
||||
};
|
||||
|
||||
public static Vec2 TileCenterToWorldPixel(int x, int y)
|
||||
=> new(x * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f,
|
||||
y * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f);
|
||||
|
||||
/// <summary>
|
||||
/// In-game seconds to traverse a single world-pixel between adjacent tiles.
|
||||
/// Combines BASE_SEC_PER_WORLD_PIXEL with biome modifier and a road bonus.
|
||||
/// </summary>
|
||||
public float SecondsPerPixel(in WorldTile t)
|
||||
{
|
||||
float biomeMod = t.Biome switch
|
||||
{
|
||||
BiomeId.MountainAlpine => 3.0f,
|
||||
BiomeId.MountainForested => 2.5f,
|
||||
BiomeId.Wetland => 2.0f,
|
||||
BiomeId.Foothills => 1.6f,
|
||||
BiomeId.Boreal => 1.5f,
|
||||
BiomeId.SubtropicalForest => 1.5f,
|
||||
BiomeId.TemperateDeciduous => 1.4f,
|
||||
BiomeId.Tundra => 1.5f,
|
||||
_ => 1.0f,
|
||||
};
|
||||
float roadMod = ((t.Features & FeatureFlags.HasRoad) != 0) ? C.ROAD_SPEED_MULT : 1f;
|
||||
return C.BASE_SEC_PER_WORLD_PIXEL * biomeMod * roadMod;
|
||||
}
|
||||
|
||||
/// <summary>In-game seconds to walk between two adjacent tile centers.</summary>
|
||||
public float EstimateSecondsForLeg(int fx, int fy, int tx, int ty)
|
||||
{
|
||||
ref var to = ref _world.TileAt(tx, ty);
|
||||
float pixDist = Vec2.Dist(TileCenterToWorldPixel(fx, fy), TileCenterToWorldPixel(tx, ty));
|
||||
return pixDist * SecondsPerPixel(to);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M2 — central dispatch for "the player consumed item X". The
|
||||
/// inventory UI's "Use" button routes here; quest effects and dialogue
|
||||
/// effects that consume items also route here. Centralising the dispatch
|
||||
/// keeps the per-kind handlers (healing potion, scent mask, …) testable
|
||||
/// in one place and makes the Hybrid Medical Incompatibility scaling
|
||||
/// (Phase 6.5 M4 carryover) apply uniformly across all heal sources.
|
||||
///
|
||||
/// Returns a <see cref="ConsumeResult"/> describing what happened so the
|
||||
/// UI can format a message and the inventory caller can decrement /
|
||||
/// remove the consumed instance.
|
||||
///
|
||||
/// Adding a new consumable kind: add the value to
|
||||
/// <see cref="ItemDef.ConsumableKind"/> in JSON, then add a
|
||||
/// <c>case</c> branch below. <see cref="ConsumeResult"/> carries the
|
||||
/// outcome a caller cares about.
|
||||
/// </summary>
|
||||
public static class ConsumableHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Consume <paramref name="item"/> for <paramref name="pc"/>, deriving
|
||||
/// any randomness from <paramref name="seed"/> (e.g. healing-dice rolls).
|
||||
/// The caller is responsible for actually removing the item from the
|
||||
/// inventory once a non-rejected result is returned.
|
||||
///
|
||||
/// <paramref name="seed"/> should be deterministic per usage —
|
||||
/// callers typically derive it from
|
||||
/// <c>worldSeed ^ characterCreationMs ^ usageIndex</c> or similar so
|
||||
/// save / load round-trips reproduce the roll.
|
||||
/// </summary>
|
||||
public static ConsumeResult Consume(ItemDef item, Character pc, ulong seed)
|
||||
{
|
||||
if (item is null) throw new System.ArgumentNullException(nameof(item));
|
||||
if (pc is null) throw new System.ArgumentNullException(nameof(pc));
|
||||
if (item.Kind != "consumable")
|
||||
return ConsumeResult.Rejected($"'{item.Id}' is not a consumable (kind='{item.Kind}').");
|
||||
|
||||
return item.ConsumableKind switch
|
||||
{
|
||||
"healing" => ConsumeHealingPotion(item, pc, seed),
|
||||
"scent_mask" => ConsumeScentMask(item, pc),
|
||||
_ => ConsumeResult.Unrecognized(item.Id),
|
||||
};
|
||||
}
|
||||
|
||||
private static ConsumeResult ConsumeHealingPotion(ItemDef item, Character pc, ulong seed)
|
||||
{
|
||||
// Healing dice — items.json field "healing" = e.g. "2d4+2".
|
||||
if (string.IsNullOrEmpty(item.Healing))
|
||||
return ConsumeResult.Rejected($"healing potion '{item.Id}' has no healing dice expression.");
|
||||
|
||||
var roll = Rules.Combat.DamageRoll.Parse(item.Healing, Rules.Stats.DamageType.Bludgeoning);
|
||||
var rng = new SeededRng(seed);
|
||||
// Average each die roll independently — same shape as the
|
||||
// resolver's DamageRoll roll path. We don't have a delegate hook
|
||||
// here; just sum the dice directly.
|
||||
int rolled = roll.FlatMod;
|
||||
for (int i = 0; i < roll.DiceCount; i++)
|
||||
rolled += rng.NextInt(1, roll.DiceSides + 1);
|
||||
if (rolled < 0) rolled = 0;
|
||||
|
||||
// Phase 6.5 M4 carryover — Hybrid Medical Incompatibility scales
|
||||
// potion healing at 0.75× (round down, min 1). Same handler the
|
||||
// Field Repair / Lay on Paws paths use; centralising here means
|
||||
// every future healing source gets it automatically.
|
||||
int delivered = HybridDetriments.ScaleHealForHybrid(pc, rolled);
|
||||
|
||||
// Apply to PC HP, capped to MaxHp.
|
||||
int before = pc.CurrentHp;
|
||||
pc.CurrentHp = System.Math.Min(pc.MaxHp, pc.CurrentHp + delivered);
|
||||
int actualHealed = pc.CurrentHp - before;
|
||||
|
||||
return ConsumeResult.Healed(actualHealed,
|
||||
wasScaledForHybrid: pc.IsHybrid && delivered != rolled);
|
||||
}
|
||||
|
||||
private static ConsumeResult ConsumeScentMask(ItemDef item, Character pc)
|
||||
{
|
||||
var tier = ParseScentMaskTier(item.Id);
|
||||
if (tier == ScentMaskTier.None)
|
||||
return ConsumeResult.Rejected($"unknown scent-mask tier on '{item.Id}'.");
|
||||
|
||||
// Hybrid PCs are the use case; non-hybrids consuming a mask is
|
||||
// mechanically a no-op (no detriments to suppress) but we still
|
||||
// accept the consume so the UI doesn't error out — flavoured as
|
||||
// "you put on a mask; nothing in particular happens".
|
||||
if (pc.Hybrid is null)
|
||||
return ConsumeResult.MaskApplied(tier, hadEffect: false);
|
||||
|
||||
pc.Hybrid.ActiveMaskTier = tier;
|
||||
return ConsumeResult.MaskApplied(tier, hadEffect: true);
|
||||
}
|
||||
|
||||
private static ScentMaskTier ParseScentMaskTier(string itemId) => itemId switch
|
||||
{
|
||||
"scent_mask_basic" => ScentMaskTier.Basic,
|
||||
"scent_mask_military" => ScentMaskTier.Military,
|
||||
"scent_mask_deep_cover" => ScentMaskTier.DeepCover,
|
||||
_ => ScentMaskTier.None,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M2 — outcome of a <see cref="ConsumableHandler.Consume"/> call.
|
||||
/// Tagged-union shape: exactly one of <see cref="HealedAmount"/>,
|
||||
/// <see cref="MaskTier"/>, <see cref="RejectedReason"/>, or
|
||||
/// <see cref="UnrecognizedItemId"/> carries the meaningful payload, keyed
|
||||
/// by <see cref="Kind"/>.
|
||||
/// </summary>
|
||||
public sealed record ConsumeResult
|
||||
{
|
||||
public enum ResultKind : byte { Healed, MaskApplied, Rejected, Unrecognized }
|
||||
|
||||
public ResultKind Kind { get; init; }
|
||||
public int HealedAmount { get; init; }
|
||||
public bool WasScaledForHybrid { get; init; }
|
||||
public ScentMaskTier MaskTier { get; init; }
|
||||
public bool MaskHadEffect { get; init; }
|
||||
public string RejectedReason { get; init; } = "";
|
||||
public string UnrecognizedItemId { get; init; } = "";
|
||||
|
||||
public bool IsSuccess => Kind == ResultKind.Healed || Kind == ResultKind.MaskApplied;
|
||||
|
||||
public static ConsumeResult Healed(int amount, bool wasScaledForHybrid)
|
||||
=> new() { Kind = ResultKind.Healed, HealedAmount = amount, WasScaledForHybrid = wasScaledForHybrid };
|
||||
|
||||
public static ConsumeResult MaskApplied(ScentMaskTier tier, bool hadEffect)
|
||||
=> new() { Kind = ResultKind.MaskApplied, MaskTier = tier, MaskHadEffect = hadEffect };
|
||||
|
||||
public static ConsumeResult Rejected(string reason)
|
||||
=> new() { Kind = ResultKind.Rejected, RejectedReason = reason };
|
||||
|
||||
public static ConsumeResult Unrecognized(string itemId)
|
||||
=> new() { Kind = ResultKind.Unrecognized, UnrecognizedItemId = itemId };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace Theriapolis.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Equipment slot a single <see cref="ItemInstance"/> occupies when worn or
|
||||
/// wielded. Each slot holds at most one item. Two-handed weapons clear the
|
||||
/// OffHand slot when equipped.
|
||||
///
|
||||
/// Natural-weapon enhancers (Fang Caps, Claw Sheaths, Hoof Plates, Antler Tips,
|
||||
/// Horn Rings) attach to a *specific* anatomical slot — they do not share with
|
||||
/// general-purpose worn items, so each gets its own enum entry.
|
||||
/// </summary>
|
||||
public enum EquipSlot : byte
|
||||
{
|
||||
MainHand = 0,
|
||||
OffHand = 1,
|
||||
Body = 2,
|
||||
Helm = 3,
|
||||
Cloak = 4,
|
||||
Boots = 5,
|
||||
AdaptivePack = 6,
|
||||
NaturalWeaponFang = 7,
|
||||
NaturalWeaponClaw = 8,
|
||||
NaturalWeaponHoof = 9,
|
||||
NaturalWeaponAntler = 10,
|
||||
NaturalWeaponHorn = 11,
|
||||
}
|
||||
|
||||
public static class EquipSlotExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps an <see cref="Data.ItemDef.EnhancerSlot"/> string ("fang", "claw",
|
||||
/// "hoof", "antler", "horn") to the corresponding NaturalWeapon* slot.
|
||||
/// Returns null if the string isn't a recognized natural-weapon location.
|
||||
/// </summary>
|
||||
public static EquipSlot? FromEnhancerSlot(string? raw) => raw?.ToLowerInvariant() switch
|
||||
{
|
||||
"fang" => EquipSlot.NaturalWeaponFang,
|
||||
"claw" => EquipSlot.NaturalWeaponClaw,
|
||||
"hoof" => EquipSlot.NaturalWeaponHoof,
|
||||
"antler" => EquipSlot.NaturalWeaponAntler,
|
||||
"horn" => EquipSlot.NaturalWeaponHorn,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Parses a snake_case JSON value (e.g. "main_hand") into an EquipSlot.</summary>
|
||||
public static EquipSlot? FromJson(string? raw) => raw?.ToLowerInvariant() switch
|
||||
{
|
||||
"main_hand" => EquipSlot.MainHand,
|
||||
"off_hand" => EquipSlot.OffHand,
|
||||
"body" => EquipSlot.Body,
|
||||
"helm" => EquipSlot.Helm,
|
||||
"cloak" => EquipSlot.Cloak,
|
||||
"boots" => EquipSlot.Boots,
|
||||
"adaptive_pack" => EquipSlot.AdaptivePack,
|
||||
"natural_weapon_fang" => EquipSlot.NaturalWeaponFang,
|
||||
"natural_weapon_claw" => EquipSlot.NaturalWeaponClaw,
|
||||
"natural_weapon_hoof" => EquipSlot.NaturalWeaponHoof,
|
||||
"natural_weapon_antler" => EquipSlot.NaturalWeaponAntler,
|
||||
"natural_weapon_horn" => EquipSlot.NaturalWeaponHorn,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using Theriapolis.Core.Data;
|
||||
|
||||
namespace Theriapolis.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// A character's items: everything they're carrying plus the per-slot equipped
|
||||
/// references. Equipped items remain in <see cref="Items"/> — equipping does
|
||||
/// not move the instance, it just sets <see cref="ItemInstance.EquippedAt"/>
|
||||
/// and registers it in <see cref="Equipped"/> for fast slot lookup.
|
||||
///
|
||||
/// Phase 5 M2 ships the basic plumbing (add/remove/equip/unequip with size
|
||||
/// checks). Encumbrance speed effects, proficiency-driven attack disadvantage,
|
||||
/// and the equip UI itself land in M3.
|
||||
/// </summary>
|
||||
public sealed class Inventory
|
||||
{
|
||||
public List<ItemInstance> Items { get; } = new();
|
||||
|
||||
/// <summary>Slot → currently-equipped instance. Missing key = empty slot.</summary>
|
||||
public Dictionary<EquipSlot, ItemInstance> Equipped { get; } = new();
|
||||
|
||||
public float TotalWeightLb
|
||||
{
|
||||
get
|
||||
{
|
||||
float w = 0f;
|
||||
foreach (var i in Items) w += i.TotalWeightLb;
|
||||
return w;
|
||||
}
|
||||
}
|
||||
|
||||
public ItemInstance Add(ItemDef def, int qty = 1)
|
||||
{
|
||||
var inst = new ItemInstance(def, qty);
|
||||
Items.Add(inst);
|
||||
return inst;
|
||||
}
|
||||
|
||||
public bool Remove(ItemInstance inst)
|
||||
{
|
||||
if (inst.EquippedAt is { } slot)
|
||||
Equipped.Remove(slot);
|
||||
return Items.Remove(inst);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equip <paramref name="item"/> into <paramref name="slot"/>. Returns false
|
||||
/// with an error message if the slot is occupied, the item isn't in this
|
||||
/// inventory, or there's a basic structural mismatch (two-handed weapon
|
||||
/// when OffHand is taken, etc.).
|
||||
///
|
||||
/// Note: this method does NOT enforce proficiency or size disadvantage —
|
||||
/// those are computed at attack-resolution time so the player can equip a
|
||||
/// wrong-size weapon and accept the penalty. Hard structural blocks only.
|
||||
/// </summary>
|
||||
public bool TryEquip(ItemInstance item, EquipSlot slot, out string error)
|
||||
{
|
||||
error = "";
|
||||
if (!Items.Contains(item))
|
||||
{
|
||||
error = "Item is not in this inventory.";
|
||||
return false;
|
||||
}
|
||||
if (Equipped.TryGetValue(slot, out var existing) && existing != item)
|
||||
{
|
||||
error = $"Slot {slot} is already occupied by {existing.Def.Name}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Two-handed weapon must clear OffHand first.
|
||||
if (slot == EquipSlot.MainHand &&
|
||||
HasProperty(item.Def, "two_handed") &&
|
||||
Equipped.TryGetValue(EquipSlot.OffHand, out var offHand))
|
||||
{
|
||||
error = $"Cannot wield two-handed weapon: OffHand holds {offHand.Def.Name}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Equipping into OffHand while MainHand has a two-handed weapon is invalid.
|
||||
if (slot == EquipSlot.OffHand &&
|
||||
Equipped.TryGetValue(EquipSlot.MainHand, out var mainHand) &&
|
||||
HasProperty(mainHand.Def, "two_handed"))
|
||||
{
|
||||
error = $"Cannot use OffHand: MainHand holds two-handed {mainHand.Def.Name}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Natural-weapon enhancer must go into a NaturalWeapon* slot matching its declared anatomy.
|
||||
if (item.Def.Kind == "natural_weapon_enhancer")
|
||||
{
|
||||
var declared = EquipSlotExtensions.FromEnhancerSlot(item.Def.EnhancerSlot);
|
||||
if (declared is null)
|
||||
{
|
||||
error = $"Item '{item.Def.Id}' has invalid enhancer_slot '{item.Def.EnhancerSlot}'.";
|
||||
return false;
|
||||
}
|
||||
if (slot != declared.Value)
|
||||
{
|
||||
error = $"Enhancer '{item.Def.Name}' fits {declared.Value}, not {slot}.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Unequip the item from any prior slot first.
|
||||
if (item.EquippedAt is { } prior && prior != slot)
|
||||
Equipped.Remove(prior);
|
||||
|
||||
Equipped[slot] = item;
|
||||
item.EquippedAt = slot;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryUnequip(EquipSlot slot, out string error)
|
||||
{
|
||||
error = "";
|
||||
if (!Equipped.TryGetValue(slot, out var inst))
|
||||
{
|
||||
error = $"Slot {slot} is empty.";
|
||||
return false;
|
||||
}
|
||||
Equipped.Remove(slot);
|
||||
inst.EquippedAt = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public ItemInstance? GetEquipped(EquipSlot slot) =>
|
||||
Equipped.TryGetValue(slot, out var i) ? i : null;
|
||||
|
||||
private static bool HasProperty(ItemDef def, string prop)
|
||||
{
|
||||
foreach (var p in def.Properties)
|
||||
if (string.Equals(p, prop, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Theriapolis.Core.Data;
|
||||
|
||||
namespace Theriapolis.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// One stack of items in an <see cref="Inventory"/>. Holds a reference to the
|
||||
/// immutable <see cref="ItemDef"/> plus per-instance state: how many in the
|
||||
/// stack, current condition, and (optionally) the slot the item is equipped
|
||||
/// into.
|
||||
///
|
||||
/// Phase 5 ships condition as a no-op (always 100); it's reserved for damage,
|
||||
/// repairs, and weapon breaking that arrive in Phase 5.5+.
|
||||
/// </summary>
|
||||
public sealed class ItemInstance
|
||||
{
|
||||
public ItemDef Def { get; }
|
||||
public int Qty { get; set; }
|
||||
|
||||
/// <summary>Condition, 0..100. 100 = pristine. Phase 5 always uses 100.</summary>
|
||||
public int Condition { get; set; } = 100;
|
||||
|
||||
/// <summary>Null while in the inventory bag; set when the item is equipped.</summary>
|
||||
public EquipSlot? EquippedAt { get; set; }
|
||||
|
||||
public ItemInstance(ItemDef def, int qty = 1)
|
||||
{
|
||||
Def = def ?? throw new ArgumentNullException(nameof(def));
|
||||
if (qty < 1) throw new ArgumentOutOfRangeException(nameof(qty), "qty must be ≥ 1");
|
||||
Qty = qty;
|
||||
}
|
||||
|
||||
public float TotalWeightLb => Def.WeightLb * Qty;
|
||||
|
||||
public override string ToString() =>
|
||||
EquippedAt is null
|
||||
? $"{Def.Name}{(Qty > 1 ? $" ×{Qty}" : "")}"
|
||||
: $"{Def.Name} (equipped: {EquippedAt})";
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Body-size compatibility check for equipment. Per equipment.md:
|
||||
/// "Most equipment comes in Small, Medium, and Large variants. Using
|
||||
/// equipment not sized for your body imposes disadvantage on relevant
|
||||
/// checks unless it has the Adaptive property."
|
||||
///
|
||||
/// Returns <see cref="MatchResult.Match"/> when the item lists the wearer's
|
||||
/// size, <see cref="MatchResult.Adaptive"/> when not listed but the item has
|
||||
/// the "adaptive" property (no penalty), and
|
||||
/// <see cref="MatchResult.WrongSize"/> otherwise (wearer takes disadvantage).
|
||||
/// </summary>
|
||||
public static class SizeMatch
|
||||
{
|
||||
public enum MatchResult : byte
|
||||
{
|
||||
Match = 0, // item explicitly fits the wearer's size
|
||||
Adaptive = 1, // item is universally adaptive — no disadvantage
|
||||
WrongSize = 2, // wearer can equip it but suffers disadvantage
|
||||
}
|
||||
|
||||
public static MatchResult Check(ItemDef def, SizeCategory wearerSize)
|
||||
{
|
||||
string wearerKey = wearerSize switch
|
||||
{
|
||||
SizeCategory.Tiny => "tiny",
|
||||
SizeCategory.Small => "small",
|
||||
SizeCategory.Medium => "medium",
|
||||
SizeCategory.MediumLarge => "medium", // M-Large uses Medium-sized gear
|
||||
SizeCategory.Large => "large",
|
||||
SizeCategory.Huge => "large", // closest available
|
||||
_ => "medium",
|
||||
};
|
||||
|
||||
foreach (var s in def.Sizes)
|
||||
if (string.Equals(s, wearerKey, StringComparison.OrdinalIgnoreCase))
|
||||
return MatchResult.Match;
|
||||
|
||||
foreach (var p in def.Properties)
|
||||
if (string.Equals(p, "adaptive", StringComparison.OrdinalIgnoreCase))
|
||||
return MatchResult.Adaptive;
|
||||
|
||||
return MatchResult.WrongSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Loot;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M2 — container-level deterministic loot rolls. Wraps
|
||||
/// <see cref="LootRoller"/> with a per-container <see cref="SeededRng"/>
|
||||
/// derived from the dungeon's layout seed and the container's slot index,
|
||||
/// so the same <c>(worldSeed, poiId, slotIdx)</c> always rolls the same
|
||||
/// items.
|
||||
///
|
||||
/// Per Phase 7 plan §4.4 / §5.5:
|
||||
/// <c>lootContainerSeed = dungeonLayoutSeed ^ C.RNG_DUNGEON_LOOT ^ slotIdx</c>
|
||||
///
|
||||
/// The <see cref="LootRoller"/> path is the encounter-drop pipeline (uses
|
||||
/// the encounter's RNG); this path is for static dungeon containers and
|
||||
/// does not advance any encounter-time stream.
|
||||
/// </summary>
|
||||
public static class LootGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Roll a single container's contents.
|
||||
/// </summary>
|
||||
/// <param name="tableId">Loot-table id (e.g. <c>loot_dungeon_imperium_t2</c>).</param>
|
||||
/// <param name="containerSeed">
|
||||
/// Per-container seed. Caller is expected to derive this as
|
||||
/// <c>worldSeed ^ C.RNG_DUNGEON_LAYOUT ^ poiId ^ C.RNG_DUNGEON_LOOT ^ slotIdx</c>.
|
||||
/// </param>
|
||||
/// <param name="tables">Loot-table dictionary from <see cref="ContentResolver.LootTables"/>.</param>
|
||||
/// <param name="items">Item dictionary from <see cref="ContentResolver.Items"/>.</param>
|
||||
/// <returns>An array of <see cref="ItemInstance"/> ready to drop into an inventory.</returns>
|
||||
public static ItemInstance[] RollContainer(
|
||||
string tableId,
|
||||
ulong containerSeed,
|
||||
IReadOnlyDictionary<string, LootTableDef> tables,
|
||||
IReadOnlyDictionary<string, ItemDef> items)
|
||||
{
|
||||
var rng = new SeededRng(containerSeed);
|
||||
var drops = LootRoller.Roll(tableId, tables, items, rng);
|
||||
var result = new ItemInstance[drops.Count];
|
||||
for (int i = 0; i < drops.Count; i++)
|
||||
result[i] = new ItemInstance(drops[i].Def, drops[i].Qty);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience overload that resolves the per-container seed from the
|
||||
/// dungeon layout seed + slot index per the Phase 7 dice contract.
|
||||
/// </summary>
|
||||
public static ItemInstance[] RollContainer(
|
||||
string tableId,
|
||||
ulong dungeonLayoutSeed,
|
||||
int slotIdx,
|
||||
IReadOnlyDictionary<string, LootTableDef> tables,
|
||||
IReadOnlyDictionary<string, ItemDef> items)
|
||||
{
|
||||
ulong containerSeed = dungeonLayoutSeed ^ C.RNG_DUNGEON_LOOT ^ (ulong)slotIdx;
|
||||
return RollContainer(tableId, containerSeed, tables, items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Loot;
|
||||
|
||||
/// <summary>
|
||||
/// Pure deterministic loot roller. Given a table id and an RNG (typically
|
||||
/// the encounter's <see cref="Rules.Combat.Encounter.RollDie"/>), produces
|
||||
/// the list of (itemDef, qty) tuples to drop.
|
||||
///
|
||||
/// Determinism: dice come from the encounter RNG so save+load round-trips
|
||||
/// produce identical drops — important for the autosave_combat retry slot.
|
||||
/// </summary>
|
||||
public static class LootRoller
|
||||
{
|
||||
public sealed record DropResult(ItemDef Def, int Qty);
|
||||
|
||||
/// <summary>
|
||||
/// Roll <paramref name="tableId"/> against the supplied RNG. Returns an
|
||||
/// empty list when the table id is empty/unknown.
|
||||
/// </summary>
|
||||
public static List<DropResult> Roll(
|
||||
string tableId,
|
||||
IReadOnlyDictionary<string, LootTableDef> tables,
|
||||
IReadOnlyDictionary<string, ItemDef> items,
|
||||
SeededRng rng)
|
||||
{
|
||||
var results = new List<DropResult>();
|
||||
if (string.IsNullOrEmpty(tableId) || !tables.TryGetValue(tableId, out var table))
|
||||
return results;
|
||||
|
||||
foreach (var drop in table.Drops)
|
||||
{
|
||||
// Independent chance roll per drop.
|
||||
if (rng.NextFloat() > drop.Chance) continue;
|
||||
if (!items.TryGetValue(drop.ItemId, out var def)) continue;
|
||||
|
||||
int qty;
|
||||
if (drop.QtyMax <= drop.QtyMin) qty = System.Math.Max(1, drop.QtyMin);
|
||||
else qty = rng.NextInt(drop.QtyMin, drop.QtyMax + 1);
|
||||
|
||||
results.Add(new DropResult(def, qty));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Core.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Converts between the live <see cref="Character"/> model and its flat
|
||||
/// serializable snapshot <see cref="PlayerCharacterState"/>. Restore needs a
|
||||
/// <see cref="ContentResolver"/> so it can re-attach to immutable defs by id.
|
||||
/// </summary>
|
||||
public static class CharacterCodec
|
||||
{
|
||||
public static PlayerCharacterState Capture(Character c)
|
||||
{
|
||||
var state = new PlayerCharacterState
|
||||
{
|
||||
CladeId = c.Clade.Id,
|
||||
SpeciesId = c.Species.Id,
|
||||
ClassId = c.ClassDef.Id,
|
||||
BackgroundId = c.Background.Id,
|
||||
STR = c.Abilities.STR,
|
||||
DEX = c.Abilities.DEX,
|
||||
CON = c.Abilities.CON,
|
||||
INT = c.Abilities.INT,
|
||||
WIS = c.Abilities.WIS,
|
||||
CHA = c.Abilities.CHA,
|
||||
Level = c.Level,
|
||||
Xp = c.Xp,
|
||||
MaxHp = c.MaxHp,
|
||||
CurrentHp = c.CurrentHp,
|
||||
ExhaustionLevel = c.ExhaustionLevel,
|
||||
FightingStyle = c.FightingStyle,
|
||||
RageUsesRemaining = c.RageUsesRemaining,
|
||||
CurrencyFang = c.CurrencyFang,
|
||||
SubclassId = c.SubclassId,
|
||||
LearnedFeatureIds = c.LearnedFeatureIds.ToArray(),
|
||||
LevelUpHistory = c.LevelUpHistory.Select(h => new LevelUpRecordState
|
||||
{
|
||||
Level = h.Level,
|
||||
HpGained = h.HpGained,
|
||||
HpWasAveraged = h.HpWasAveraged,
|
||||
HpHitDieResult = h.HpHitDieResult,
|
||||
SubclassChosen = h.SubclassChosen ?? "",
|
||||
AsiKeys = h.AsiAdjustmentsKeys,
|
||||
AsiValues = h.AsiAdjustmentsValues,
|
||||
FeaturesUnlocked = h.FeaturesUnlocked,
|
||||
}).ToArray(),
|
||||
// Phase 6.5 M4 — hybrid state. Null for purebred PCs.
|
||||
// Phase 6.5 M5 adds ActiveMaskTier.
|
||||
Hybrid = c.Hybrid is null ? null : new HybridStateSnapshot
|
||||
{
|
||||
SireClade = c.Hybrid.SireClade,
|
||||
SireSpecies = c.Hybrid.SireSpecies,
|
||||
DamClade = c.Hybrid.DamClade,
|
||||
DamSpecies = c.Hybrid.DamSpecies,
|
||||
DominantParent = (byte)c.Hybrid.DominantParent,
|
||||
PassingActive = c.Hybrid.PassingActive,
|
||||
NpcsWhoKnow = c.Hybrid.NpcsWhoKnow.ToArray(),
|
||||
ActiveMaskTier = (byte)c.Hybrid.ActiveMaskTier,
|
||||
},
|
||||
};
|
||||
|
||||
var skills = new byte[c.SkillProficiencies.Count];
|
||||
int i = 0;
|
||||
foreach (var s in c.SkillProficiencies) skills[i++] = (byte)s;
|
||||
state.SkillProficiencies = skills;
|
||||
|
||||
var conds = new byte[c.Conditions.Count];
|
||||
i = 0;
|
||||
foreach (var x in c.Conditions) conds[i++] = (byte)x;
|
||||
state.Conditions = conds;
|
||||
|
||||
var inv = new InventoryItemState[c.Inventory.Items.Count];
|
||||
for (int k = 0; k < c.Inventory.Items.Count; k++)
|
||||
{
|
||||
var it = c.Inventory.Items[k];
|
||||
inv[k] = new InventoryItemState
|
||||
{
|
||||
ItemId = it.Def.Id,
|
||||
Qty = it.Qty,
|
||||
Condition = it.Condition,
|
||||
EquippedAt = it.EquippedAt is { } slot ? (byte)slot : null,
|
||||
};
|
||||
}
|
||||
state.Inventory = inv;
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
public static Character Restore(PlayerCharacterState state, ContentResolver content)
|
||||
{
|
||||
if (!content.Clades.TryGetValue(state.CladeId, out var clade))
|
||||
throw new InvalidDataException($"Save references unknown clade '{state.CladeId}'.");
|
||||
if (!content.Species.TryGetValue(state.SpeciesId, out var species))
|
||||
throw new InvalidDataException($"Save references unknown species '{state.SpeciesId}'.");
|
||||
if (!content.Classes.TryGetValue(state.ClassId, out var classDef))
|
||||
throw new InvalidDataException($"Save references unknown class '{state.ClassId}'.");
|
||||
if (!content.Backgrounds.TryGetValue(state.BackgroundId, out var bg))
|
||||
throw new InvalidDataException($"Save references unknown background '{state.BackgroundId}'.");
|
||||
|
||||
var abilities = new AbilityScores(state.STR, state.DEX, state.CON, state.INT, state.WIS, state.CHA);
|
||||
var c = new Character(clade, species, classDef, bg, abilities)
|
||||
{
|
||||
Level = state.Level,
|
||||
Xp = state.Xp,
|
||||
MaxHp = state.MaxHp,
|
||||
CurrentHp = state.CurrentHp,
|
||||
ExhaustionLevel = state.ExhaustionLevel,
|
||||
FightingStyle = state.FightingStyle,
|
||||
RageUsesRemaining = state.RageUsesRemaining,
|
||||
CurrencyFang = state.CurrencyFang,
|
||||
SubclassId = state.SubclassId ?? "",
|
||||
};
|
||||
|
||||
// Phase 6.5 M0 — restore learned features + level-up history.
|
||||
if (state.LearnedFeatureIds is not null)
|
||||
foreach (var fid in state.LearnedFeatureIds)
|
||||
c.LearnedFeatureIds.Add(fid);
|
||||
|
||||
if (state.LevelUpHistory is not null)
|
||||
{
|
||||
foreach (var h in state.LevelUpHistory)
|
||||
{
|
||||
c.LevelUpHistory.Add(new LevelUpRecord
|
||||
{
|
||||
Level = h.Level,
|
||||
HpGained = h.HpGained,
|
||||
HpWasAveraged = h.HpWasAveraged,
|
||||
HpHitDieResult = h.HpHitDieResult,
|
||||
SubclassChosen = string.IsNullOrEmpty(h.SubclassChosen) ? null : h.SubclassChosen,
|
||||
AsiAdjustmentsKeys = h.AsiKeys ?? Array.Empty<byte>(),
|
||||
AsiAdjustmentsValues = h.AsiValues ?? Array.Empty<int>(),
|
||||
FeaturesUnlocked = h.FeaturesUnlocked ?? Array.Empty<string>(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6.5 M4 — restore hybrid state when present.
|
||||
if (state.Hybrid is not null)
|
||||
{
|
||||
c.Hybrid = new HybridState
|
||||
{
|
||||
SireClade = state.Hybrid.SireClade,
|
||||
SireSpecies = state.Hybrid.SireSpecies,
|
||||
DamClade = state.Hybrid.DamClade,
|
||||
DamSpecies = state.Hybrid.DamSpecies,
|
||||
DominantParent = (ParentLineage)state.Hybrid.DominantParent,
|
||||
PassingActive = state.Hybrid.PassingActive,
|
||||
ActiveMaskTier = (ScentMaskTier)state.Hybrid.ActiveMaskTier,
|
||||
};
|
||||
foreach (int npcId in state.Hybrid.NpcsWhoKnow ?? Array.Empty<int>())
|
||||
c.Hybrid.NpcsWhoKnow.Add(npcId);
|
||||
}
|
||||
|
||||
foreach (var s in state.SkillProficiencies) c.SkillProficiencies.Add((SkillId)s);
|
||||
foreach (var x in state.Conditions) c.Conditions.Add((Condition)x);
|
||||
|
||||
foreach (var it in state.Inventory)
|
||||
{
|
||||
if (!content.Items.TryGetValue(it.ItemId, out var def))
|
||||
throw new InvalidDataException($"Save references unknown item '{it.ItemId}'.");
|
||||
var inst = c.Inventory.Add(def, it.Qty);
|
||||
inst.Condition = it.Condition;
|
||||
if (it.EquippedAt is { } slotByte)
|
||||
{
|
||||
var slot = (EquipSlot)slotByte;
|
||||
if (!c.Inventory.TryEquip(inst, slot, out var err))
|
||||
throw new InvalidDataException($"Could not re-equip '{it.ItemId}' into {slot}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace Theriapolis.Core.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Mid-encounter snapshot. Present in <see cref="SaveBody"/> only when the
|
||||
/// player saved during combat. On load, the live PlayScreen re-creates a
|
||||
/// <see cref="Rules.Combat.Encounter"/> with the same participants and
|
||||
/// calls <c>ResumeRolls(RollCount)</c> so the dice stream continues from
|
||||
/// the same sequence point — see Phase 5 plan §5.
|
||||
/// </summary>
|
||||
public sealed class EncounterState
|
||||
{
|
||||
public ulong EncounterId { get; set; }
|
||||
public int RollCount { get; set; }
|
||||
public int CurrentTurnIndex { get; set; }
|
||||
public int RoundNumber { get; set; }
|
||||
public int[] InitiativeOrder { get; set; } = System.Array.Empty<int>();
|
||||
public CombatantSnapshot[] Combatants { get; set; } = System.Array.Empty<CombatantSnapshot>();
|
||||
}
|
||||
|
||||
/// <summary>One combatant in the saved encounter.</summary>
|
||||
public sealed class CombatantSnapshot
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>Display name (used to verify the same combatant on resume).</summary>
|
||||
public string Name { get; set; } = "";
|
||||
/// <summary>True if this combatant is the player. False = NPC.</summary>
|
||||
public bool IsPlayer { get; set; }
|
||||
/// <summary>For NPC combatants: the chunk + spawn index that produced them, used to find the live <c>NpcActor</c> on resume.</summary>
|
||||
public int? NpcChunkX { get; set; }
|
||||
public int? NpcChunkY { get; set; }
|
||||
public int? NpcSpawnIndex { get; set; }
|
||||
/// <summary>For NPC combatants: the template id (used as fallback identity if chunk lookup fails).</summary>
|
||||
public string NpcTemplateId { get; set; } = "";
|
||||
public int CurrentHp { get; set; }
|
||||
public float PositionX { get; set; }
|
||||
public float PositionY { get; set; }
|
||||
/// <summary>Active conditions as enum byte values.</summary>
|
||||
public byte[] Conditions { get; set; } = System.Array.Empty<byte>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-chunk roster delta — which spawn indices have been killed (or
|
||||
/// otherwise consumed). Layered on top of the chunk's deterministic spawn
|
||||
/// list so reloading a chunk doesn't resurrect dead enemies.
|
||||
/// </summary>
|
||||
public sealed class NpcRosterState
|
||||
{
|
||||
public List<NpcChunkDelta> ChunkDeltas { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class NpcChunkDelta
|
||||
{
|
||||
public int ChunkX { get; set; }
|
||||
public int ChunkY { get; set; }
|
||||
/// <summary>Spawn-list indices in this chunk whose NPC has died.</summary>
|
||||
public int[] KilledSpawnIndices { get; set; } = System.Array.Empty<int>();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Theriapolis.Core.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Modules that own save-worthy state implement this interface.
|
||||
/// CaptureState produces a serializable snapshot; RestoreState applies one
|
||||
/// over the live module. Phase 5/6 add new persistables (faction/quest/rep)
|
||||
/// without churning the SaveBody schema, by adding new IPersistable types
|
||||
/// and one new field on SaveBody.
|
||||
/// </summary>
|
||||
public interface IPersistable<TState>
|
||||
{
|
||||
TState CaptureState();
|
||||
void RestoreState(TState state);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
namespace Theriapolis.Core.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Plain-data snapshot of a <see cref="Rules.Character.Character"/> for
|
||||
/// the save layer. Kept as primitive fields + arrays so the persistence
|
||||
/// codec doesn't pull MessagePack attributes onto the live model.
|
||||
///
|
||||
/// Items / equipped slots reference content by id (string); the loader
|
||||
/// re-resolves them against the current <see cref="Data.ItemDef"/> set
|
||||
/// after world content reloads.
|
||||
/// </summary>
|
||||
public sealed class PlayerCharacterState
|
||||
{
|
||||
public string CladeId { get; set; } = "";
|
||||
public string SpeciesId { get; set; } = "";
|
||||
public string ClassId { get; set; } = "";
|
||||
public string BackgroundId { get; set; } = "";
|
||||
|
||||
// Final ability scores (post clade + species mods).
|
||||
public byte STR { get; set; }
|
||||
public byte DEX { get; set; }
|
||||
public byte CON { get; set; }
|
||||
public byte INT { get; set; }
|
||||
public byte WIS { get; set; }
|
||||
public byte CHA { get; set; }
|
||||
|
||||
public int Level { get; set; } = 1;
|
||||
public int Xp { get; set; } = 0;
|
||||
public int MaxHp { get; set; }
|
||||
public int CurrentHp { get; set; }
|
||||
public int ExhaustionLevel { get; set; } = 0;
|
||||
|
||||
// Phase 5 M6 additions — backward-compat handled by EndOfStream check in SaveCodec.ReadCharacter.
|
||||
public string FightingStyle { get; set; } = "";
|
||||
public int RageUsesRemaining { get; set; } = 2;
|
||||
|
||||
// Phase 6 M3 — coin balance (Fangs are Theriapolis's universal currency).
|
||||
public int CurrencyFang { get; set; } = 0;
|
||||
|
||||
// Phase 6.5 M0 — levelling state.
|
||||
/// <summary>
|
||||
/// Subclass id chosen at level 3 (or whatever
|
||||
/// <see cref="C.SUBCLASS_SELECTION_LEVEL"/> is). Empty pre-L3.
|
||||
/// </summary>
|
||||
public string SubclassId { get; set; } = "";
|
||||
|
||||
/// <summary>Feature ids learned across all level-ups, in unlock order.</summary>
|
||||
public string[] LearnedFeatureIds { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Append-only per-level-up history (deltas, not post-state).</summary>
|
||||
public LevelUpRecordState[] LevelUpHistory { get; set; } = Array.Empty<LevelUpRecordState>();
|
||||
|
||||
// Phase 6.5 M4 — hybrid-character genealogy. Null for purebred PCs.
|
||||
public HybridStateSnapshot? Hybrid { get; set; }
|
||||
|
||||
/// <summary>Skill ids stored as enum byte values for compactness.</summary>
|
||||
public byte[] SkillProficiencies { get; set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>Active conditions stored as enum byte values.</summary>
|
||||
public byte[] Conditions { get; set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>Inventory stacks — references to ItemDef.Id with quantity + condition + optional equip slot.</summary>
|
||||
public InventoryItemState[] Inventory { get; set; } = Array.Empty<InventoryItemState>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M0 — one entry in <see cref="PlayerCharacterState.LevelUpHistory"/>.
|
||||
/// Plain-data round-trip of <see cref="Rules.Character.LevelUpRecord"/>.
|
||||
/// </summary>
|
||||
public sealed class LevelUpRecordState
|
||||
{
|
||||
public int Level { get; set; }
|
||||
public int HpGained { get; set; }
|
||||
public bool HpWasAveraged { get; set; }
|
||||
public int HpHitDieResult { get; set; }
|
||||
/// <summary>Empty when no subclass was chosen at this level (i.e. anything but L3).</summary>
|
||||
public string SubclassChosen { get; set; } = "";
|
||||
/// <summary>ASI ability ids stored as enum byte values; same length as <see cref="AsiValues"/>.</summary>
|
||||
public byte[] AsiKeys { get; set; } = Array.Empty<byte>();
|
||||
public int[] AsiValues { get; set; } = Array.Empty<int>();
|
||||
public string[] FeaturesUnlocked { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M4 — flat round-trip record for <see cref="Rules.Character.HybridState"/>.
|
||||
/// </summary>
|
||||
public sealed class HybridStateSnapshot
|
||||
{
|
||||
public string SireClade { get; set; } = "";
|
||||
public string SireSpecies { get; set; } = "";
|
||||
public string DamClade { get; set; } = "";
|
||||
public string DamSpecies { get; set; } = "";
|
||||
/// <summary>0 = Sire, 1 = Dam (matches <see cref="Rules.Character.ParentLineage"/> byte values).</summary>
|
||||
public byte DominantParent { get; set; } = 0;
|
||||
public bool PassingActive { get; set; } = false;
|
||||
/// <summary>Per-NPC discovery list — populated in Phase 6.5 M5.</summary>
|
||||
public int[] NpcsWhoKnow { get; set; } = Array.Empty<int>();
|
||||
/// <summary>Active scent-mask tier (Phase 6.5 M5). 0 = none.</summary>
|
||||
public byte ActiveMaskTier { get; set; } = 0;
|
||||
}
|
||||
|
||||
/// <summary>One stack in <see cref="PlayerCharacterState.Inventory"/>.</summary>
|
||||
public sealed class InventoryItemState
|
||||
{
|
||||
public string ItemId { get; set; } = "";
|
||||
public int Qty { get; set; } = 1;
|
||||
public int Condition { get; set; } = 100;
|
||||
/// <summary>Null if this stack is bagged. Otherwise the EquipSlot enum byte value.</summary>
|
||||
public byte? EquippedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Theriapolis.Core.Rules.Quests;
|
||||
|
||||
namespace Theriapolis.Core.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M4 — bidirectional translator between the live
|
||||
/// <see cref="QuestEngine"/> and its serializable
|
||||
/// <see cref="QuestSnapshot"/>.
|
||||
/// </summary>
|
||||
public static class QuestCodec
|
||||
{
|
||||
public static QuestSnapshot Capture(QuestEngine engine)
|
||||
{
|
||||
var snap = new QuestSnapshot();
|
||||
foreach (var s in engine.Active.Values) snap.Active.Add(CaptureState(s));
|
||||
foreach (var s in engine.Completed.Values) snap.Completed.Add(CaptureState(s));
|
||||
foreach (var line in engine.Journal) snap.Journal.Add(line);
|
||||
return snap;
|
||||
}
|
||||
|
||||
public static void Restore(QuestEngine engine, QuestSnapshot snap)
|
||||
{
|
||||
engine.Clear();
|
||||
foreach (var s in snap.Active) engine.AdoptActive(RestoreState(s));
|
||||
foreach (var s in snap.Completed) engine.AdoptCompleted(RestoreState(s));
|
||||
foreach (var line in snap.Journal) engine.Journal.Add(line);
|
||||
}
|
||||
|
||||
private static QuestStateSnapshot CaptureState(QuestState s) => new()
|
||||
{
|
||||
QuestId = s.QuestId,
|
||||
CurrentStep = s.CurrentStep,
|
||||
Status = (byte)s.Status,
|
||||
StartedAt = s.StartedAt,
|
||||
StepStartedAt = s.StepStartedAt,
|
||||
JournalLines = s.Journal.ToArray(),
|
||||
};
|
||||
|
||||
private static QuestState RestoreState(QuestStateSnapshot s)
|
||||
{
|
||||
var st = new QuestState
|
||||
{
|
||||
QuestId = s.QuestId,
|
||||
CurrentStep = s.CurrentStep,
|
||||
Status = (QuestStatus)s.Status,
|
||||
StartedAt = s.StartedAt,
|
||||
StepStartedAt = s.StepStartedAt,
|
||||
};
|
||||
foreach (var line in s.JournalLines) st.Journal.Add(line);
|
||||
return st;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Theriapolis.Core.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M4 — serializable quest engine state. Holds active + completed
|
||||
/// quests + the player journal tail. Round-trips via SaveCodec
|
||||
/// <c>TAG_QUESTS = 111</c>.
|
||||
/// </summary>
|
||||
public sealed class QuestSnapshot
|
||||
{
|
||||
public List<QuestStateSnapshot> Active { get; set; } = new();
|
||||
public List<QuestStateSnapshot> Completed { get; set; } = new();
|
||||
|
||||
/// <summary>Most recent journal entries written by the engine.</summary>
|
||||
public List<string> Journal { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class QuestStateSnapshot
|
||||
{
|
||||
public string QuestId { get; set; } = "";
|
||||
public string CurrentStep { get; set; } = "";
|
||||
public byte Status { get; set; } // QuestStatus byte value
|
||||
public long StartedAt { get; set; }
|
||||
public long StepStartedAt { get; set; }
|
||||
public string[] JournalLines { get; set; } = System.Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Theriapolis.Core.Rules.Reputation;
|
||||
|
||||
namespace Theriapolis.Core.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M2 — bidirectional translator between the live
|
||||
/// <see cref="PlayerReputation"/> aggregate and its serializable
|
||||
/// <see cref="ReputationSnapshot"/>.
|
||||
/// </summary>
|
||||
public static class ReputationCodec
|
||||
{
|
||||
public static ReputationSnapshot Capture(PlayerReputation rep)
|
||||
{
|
||||
var snap = new ReputationSnapshot();
|
||||
|
||||
// Faction standings.
|
||||
foreach (var (k, v) in rep.Factions.Standings)
|
||||
snap.FactionStandings[k] = v;
|
||||
|
||||
// Personal records.
|
||||
foreach (var pd in rep.Personal.Values)
|
||||
{
|
||||
snap.Personal.Add(new PersonalDispositionSnapshot
|
||||
{
|
||||
RoleTag = pd.RoleTag,
|
||||
Score = pd.Score,
|
||||
Trust = (byte)pd.Trust,
|
||||
Betrayed = pd.Betrayed,
|
||||
LastInteractionSeconds = pd.LastInteractionSeconds,
|
||||
MemoryTags = pd.Memory.ToArray(),
|
||||
Log = pd.Log.Select(CaptureEvent).ToArray(),
|
||||
});
|
||||
}
|
||||
|
||||
// Ledger.
|
||||
foreach (var ev in rep.Ledger.Entries)
|
||||
snap.Ledger.Add(CaptureEvent(ev));
|
||||
|
||||
return snap;
|
||||
}
|
||||
|
||||
public static PlayerReputation Restore(ReputationSnapshot snap)
|
||||
{
|
||||
var rep = new PlayerReputation();
|
||||
foreach (var (k, v) in snap.FactionStandings)
|
||||
rep.Factions.Set(k, v);
|
||||
foreach (var p in snap.Personal)
|
||||
{
|
||||
var pd = new PersonalDisposition
|
||||
{
|
||||
RoleTag = p.RoleTag,
|
||||
Score = p.Score,
|
||||
Trust = (TrustLevel)p.Trust,
|
||||
Betrayed = p.Betrayed,
|
||||
LastInteractionSeconds = p.LastInteractionSeconds,
|
||||
};
|
||||
foreach (var m in p.MemoryTags) pd.Memory.Add(m);
|
||||
foreach (var ev in p.Log) pd.Log.Add(RestoreEvent(ev));
|
||||
rep.Personal[p.RoleTag] = pd;
|
||||
}
|
||||
foreach (var ev in snap.Ledger) rep.Ledger.Append(RestoreEvent(ev));
|
||||
return rep;
|
||||
}
|
||||
|
||||
private static RepEventSnapshot CaptureEvent(RepEvent ev) => new()
|
||||
{
|
||||
SequenceId = ev.SequenceId,
|
||||
Kind = (byte)ev.Kind,
|
||||
FactionId = ev.FactionId,
|
||||
RoleTag = ev.RoleTag,
|
||||
Magnitude = ev.Magnitude,
|
||||
Note = ev.Note,
|
||||
OriginTileX = ev.OriginTileX,
|
||||
OriginTileY = ev.OriginTileY,
|
||||
TimestampSeconds = ev.TimestampSeconds,
|
||||
};
|
||||
|
||||
private static RepEvent RestoreEvent(RepEventSnapshot s) => new()
|
||||
{
|
||||
SequenceId = s.SequenceId,
|
||||
Kind = (RepEventKind)s.Kind,
|
||||
FactionId = s.FactionId,
|
||||
RoleTag = s.RoleTag,
|
||||
Magnitude = s.Magnitude,
|
||||
Note = s.Note,
|
||||
OriginTileX = s.OriginTileX,
|
||||
OriginTileY = s.OriginTileY,
|
||||
TimestampSeconds = s.TimestampSeconds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Theriapolis.Core.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M2 — serializable snapshot of <see cref="Rules.Reputation.PlayerReputation"/>.
|
||||
/// Plain-data fields only; rebuilt back into the live aggregate by the
|
||||
/// PlayScreen restore path.
|
||||
///
|
||||
/// Round-trips via <c>SaveCodec</c> tags 110 (faction standings) and 112
|
||||
/// (reputation aggregate). The split lets the codec emit faction
|
||||
/// standings even when no personal records exist, keeping save files
|
||||
/// small for short playthroughs.
|
||||
/// </summary>
|
||||
public sealed class ReputationSnapshot
|
||||
{
|
||||
/// <summary>Faction id → integer standing in <c>±C.REP_MAX</c>.</summary>
|
||||
public Dictionary<string, int> FactionStandings { get; set; } = new();
|
||||
|
||||
/// <summary>Per-NPC personal records, keyed by role tag.</summary>
|
||||
public List<PersonalDispositionSnapshot> Personal { get; set; } = new();
|
||||
|
||||
/// <summary>Most recent <see cref="Rules.Reputation.RepLedger.MaxEntries"/> events.</summary>
|
||||
public List<RepEventSnapshot> Ledger { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class PersonalDispositionSnapshot
|
||||
{
|
||||
public string RoleTag { get; set; } = "";
|
||||
public int Score { get; set; }
|
||||
public byte Trust { get; set; } // TrustLevel byte value
|
||||
public bool Betrayed { get; set; }
|
||||
public long LastInteractionSeconds { get; set; }
|
||||
public string[] MemoryTags { get; set; } = System.Array.Empty<string>();
|
||||
public RepEventSnapshot[] Log { get; set; } = System.Array.Empty<RepEventSnapshot>();
|
||||
}
|
||||
|
||||
public sealed class RepEventSnapshot
|
||||
{
|
||||
public int SequenceId { get; set; } // Phase 6 M5
|
||||
public byte Kind { get; set; } // RepEventKind byte value
|
||||
public string FactionId { get; set; } = "";
|
||||
public string RoleTag { get; set; } = "";
|
||||
public int Magnitude { get; set; }
|
||||
public string Note { get; set; } = "";
|
||||
public int OriginTileX { get; set; }
|
||||
public int OriginTileY { get; set; }
|
||||
public long TimestampSeconds { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.Time;
|
||||
|
||||
namespace Theriapolis.Core.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// All save-worthy state outside the seed-derivable world. Phase 4 fills the
|
||||
/// player, clock, chunk deltas, and world-tile deltas; the other fields are
|
||||
/// reserved for Phase 5/6 so adding them later doesn't require a schema bump.
|
||||
/// </summary>
|
||||
public sealed class SaveBody
|
||||
{
|
||||
public PlayerActorState Player { get; set; } = new();
|
||||
public WorldClockState Clock { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5: full character snapshot (clade, species, class, abilities,
|
||||
/// HP, inventory). Null on Phase-4 saves; the loader treats null as a
|
||||
/// migration-failed signal and refuses the save.
|
||||
/// </summary>
|
||||
public PlayerCharacterState? PlayerCharacter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M5: per-chunk NPC roster delta (which spawn indices have died).
|
||||
/// Empty on a fresh save; populated as the player kills NPCs.
|
||||
/// </summary>
|
||||
public NpcRosterState NpcRoster { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M5: present only when the player saved mid-combat. On load,
|
||||
/// PlayScreen re-creates the encounter from this snapshot and pushes
|
||||
/// CombatHUDScreen directly without going through the title.
|
||||
/// </summary>
|
||||
public EncounterState? ActiveEncounter { get; set; }
|
||||
|
||||
/// <summary>Per-chunk player-modification overlay.</summary>
|
||||
public Dictionary<ChunkCoord, ChunkDelta> ModifiedChunks { get; set; } = new();
|
||||
|
||||
/// <summary>Sparse per-world-tile changes (e.g. burned settlement).</summary>
|
||||
public List<WorldTileDelta> ModifiedWorldTiles { get; set; } = new();
|
||||
|
||||
// ── Reserved for later phases ────────────────────────────────────────
|
||||
// Empty containers so save schema doesn't need a migration when each
|
||||
// subsystem comes online.
|
||||
public Dictionary<string, int> Flags { get; set; } = new();
|
||||
/// <summary>v5 placeholder. Phase 6 M2 superseded by <see cref="ReputationState"/>.</summary>
|
||||
public Dictionary<int, int> Factions { get; set; } = new();
|
||||
/// <summary>v5 placeholder. Phase 6 M4 superseded by <see cref="QuestEngineState"/>.</summary>
|
||||
public List<int> QuestState { get; set; } = new();
|
||||
/// <summary>v5 placeholder. Phase 6 M2 superseded by <see cref="ReputationState"/>.</summary>
|
||||
public Dictionary<string, int> Reputation { get; set; } = new();
|
||||
public List<int> DiscoveredPoiIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M2 — full reputation snapshot (faction standings, per-NPC
|
||||
/// personal dispositions, recent ledger). Empty on a fresh game; the
|
||||
/// V5→V6 migration leaves this empty too (Phase-5 saves never
|
||||
/// accumulated rep state).
|
||||
/// </summary>
|
||||
public ReputationSnapshot ReputationState { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M4 — quest engine snapshot. Empty before any quest
|
||||
/// activates. Named <c>QuestEngineState</c> to avoid colliding with
|
||||
/// the v5 placeholder <see cref="QuestState"/> dictionary.
|
||||
/// </summary>
|
||||
public QuestSnapshot QuestEngineState { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One world-tile cell that diverged from worldgen baseline.</summary>
|
||||
public readonly struct WorldTileDelta
|
||||
{
|
||||
public readonly ushort X;
|
||||
public readonly ushort Y;
|
||||
public readonly byte NewBiome;
|
||||
public readonly ushort NewFeatures;
|
||||
public WorldTileDelta(int x, int y, byte biome, ushort features)
|
||||
{
|
||||
X = (ushort)x; Y = (ushort)y; NewBiome = biome; NewFeatures = features;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.Time;
|
||||
|
||||
namespace Theriapolis.Core.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Read/write the on-disk save format:
|
||||
///
|
||||
/// [4 bytes: headerLen (uint32 LE)]
|
||||
/// [headerLen bytes: header JSON (UTF-8)]
|
||||
/// [remaining: SaveBody binary blob]
|
||||
///
|
||||
/// The body is hand-rolled binary rather than MessagePack so we don't pull a
|
||||
/// new nuget into Core. The format is purely additive — any new field becomes
|
||||
/// a new tagged section, which keeps Phase 5/6 expansion smooth.
|
||||
/// </summary>
|
||||
public static class SaveCodec
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
// Body section tags. New sections in later phases get new tags >= 100.
|
||||
private const byte TAG_END = 0;
|
||||
private const byte TAG_PLAYER = 1;
|
||||
private const byte TAG_CLOCK = 2;
|
||||
private const byte TAG_CHUNKS = 3;
|
||||
private const byte TAG_WTDELTA = 4;
|
||||
private const byte TAG_FLAGS = 5;
|
||||
// Phase 5 additions:
|
||||
private const byte TAG_CHARACTER = 100;
|
||||
private const byte TAG_NPC_ROSTER = 101;
|
||||
private const byte TAG_ENCOUNTER = 102;
|
||||
// Phase 6 additions:
|
||||
private const byte TAG_FACTION_STANDINGS = 110;
|
||||
private const byte TAG_QUESTS = 111;
|
||||
private const byte TAG_REPUTATION = 112;
|
||||
// 113 reserved for TAG_ANCHORS (Phase 6 M5/save-anywhere if needed).
|
||||
// 114 reserved for TAG_BUILDINGS (Phase 6 M5).
|
||||
|
||||
public static byte[] Serialize(SaveHeader header, SaveBody body)
|
||||
{
|
||||
// Header JSON
|
||||
string headerJson = JsonSerializer.Serialize(header, JsonOptions);
|
||||
byte[] headerBytes = Encoding.UTF8.GetBytes(headerJson);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var w = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
w.Write((uint)headerBytes.Length);
|
||||
w.Write(headerBytes);
|
||||
|
||||
// Body
|
||||
WriteBody(w, body);
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
public static (SaveHeader header, SaveBody body) Deserialize(byte[] data)
|
||||
{
|
||||
using var ms = new MemoryStream(data);
|
||||
using var r = new BinaryReader(ms, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
uint headerLen = r.ReadUInt32();
|
||||
byte[] headerBytes = r.ReadBytes((int)headerLen);
|
||||
if (headerBytes.Length != headerLen)
|
||||
throw new InvalidDataException("Save header truncated.");
|
||||
|
||||
string headerJson = Encoding.UTF8.GetString(headerBytes);
|
||||
var header = JsonSerializer.Deserialize<SaveHeader>(headerJson, JsonOptions)
|
||||
?? throw new InvalidDataException("Save header empty.");
|
||||
|
||||
var body = ReadBody(r);
|
||||
return (header, body);
|
||||
}
|
||||
|
||||
/// <summary>Reads ONLY the JSON header, leaving the body unread. Used by the slot picker.</summary>
|
||||
public static SaveHeader DeserializeHeaderOnly(byte[] data)
|
||||
{
|
||||
if (data.Length < 4) throw new InvalidDataException("Save file too short.");
|
||||
uint headerLen = BitConverter.ToUInt32(data, 0);
|
||||
if (headerLen + 4 > data.Length) throw new InvalidDataException("Save header truncated.");
|
||||
string headerJson = Encoding.UTF8.GetString(data, 4, (int)headerLen);
|
||||
return JsonSerializer.Deserialize<SaveHeader>(headerJson, JsonOptions)
|
||||
?? throw new InvalidDataException("Save header empty.");
|
||||
}
|
||||
|
||||
// ── Body writer ───────────────────────────────────────────────────────
|
||||
|
||||
private static void WriteBody(BinaryWriter w, SaveBody body)
|
||||
{
|
||||
WriteSection(w, TAG_PLAYER, bw => WritePlayer(bw, body.Player));
|
||||
WriteSection(w, TAG_CLOCK, bw => bw.Write(body.Clock.InGameSeconds));
|
||||
WriteSection(w, TAG_CHUNKS, bw => WriteChunkDeltas(bw, body.ModifiedChunks));
|
||||
WriteSection(w, TAG_WTDELTA, bw => WriteWorldTileDeltas(bw, body.ModifiedWorldTiles));
|
||||
WriteSection(w, TAG_FLAGS, bw => WriteFlags(bw, body.Flags));
|
||||
if (body.PlayerCharacter is not null)
|
||||
WriteSection(w, TAG_CHARACTER, bw => WriteCharacter(bw, body.PlayerCharacter));
|
||||
if (body.NpcRoster.ChunkDeltas.Count > 0)
|
||||
WriteSection(w, TAG_NPC_ROSTER, bw => WriteNpcRoster(bw, body.NpcRoster));
|
||||
if (body.ActiveEncounter is not null)
|
||||
WriteSection(w, TAG_ENCOUNTER, bw => WriteEncounter(bw, body.ActiveEncounter));
|
||||
// Phase 6 M2 — reputation. Faction standings ride a separate tag from
|
||||
// the personal/ledger payload so a "no personal records yet" save
|
||||
// doesn't pay the cost of an empty section.
|
||||
if (body.ReputationState.FactionStandings.Count > 0)
|
||||
WriteSection(w, TAG_FACTION_STANDINGS, bw => WriteFactionStandings(bw, body.ReputationState.FactionStandings));
|
||||
if (body.ReputationState.Personal.Count > 0 || body.ReputationState.Ledger.Count > 0)
|
||||
WriteSection(w, TAG_REPUTATION, bw => WriteReputation(bw, body.ReputationState));
|
||||
// Phase 6 M4 — quest engine snapshot.
|
||||
if (body.QuestEngineState.Active.Count > 0 || body.QuestEngineState.Completed.Count > 0 || body.QuestEngineState.Journal.Count > 0)
|
||||
WriteSection(w, TAG_QUESTS, bw => WriteQuests(bw, body.QuestEngineState));
|
||||
w.Write(TAG_END);
|
||||
}
|
||||
|
||||
private static void WriteSection(BinaryWriter w, byte tag, Action<BinaryWriter> body)
|
||||
{
|
||||
w.Write(tag);
|
||||
// Length-prefix the section so unknown tags can be skipped on read.
|
||||
using var ms = new MemoryStream();
|
||||
using (var inner = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true))
|
||||
body(inner);
|
||||
var bytes = ms.ToArray();
|
||||
w.Write((uint)bytes.Length);
|
||||
w.Write(bytes);
|
||||
}
|
||||
|
||||
private static void WritePlayer(BinaryWriter w, PlayerActorState p)
|
||||
{
|
||||
w.Write(p.Id);
|
||||
WriteString(w, p.Name);
|
||||
w.Write(p.PositionX);
|
||||
w.Write(p.PositionY);
|
||||
w.Write(p.FacingAngleRad);
|
||||
w.Write(p.SpeedWorldPxPerSec);
|
||||
w.Write(p.HighestTierReached);
|
||||
w.Write(p.DiscoveredPoiIds.Length);
|
||||
foreach (int id in p.DiscoveredPoiIds) w.Write(id);
|
||||
}
|
||||
|
||||
private static void WriteChunkDeltas(BinaryWriter w, IReadOnlyDictionary<ChunkCoord, ChunkDelta> chunks)
|
||||
{
|
||||
w.Write(chunks.Count);
|
||||
foreach (var kv in chunks)
|
||||
{
|
||||
w.Write(kv.Key.X);
|
||||
w.Write(kv.Key.Y);
|
||||
w.Write(kv.Value.SpawnsConsumed);
|
||||
w.Write(kv.Value.TileMods.Count);
|
||||
foreach (var m in kv.Value.TileMods)
|
||||
{
|
||||
w.Write(m.LocalX);
|
||||
w.Write(m.LocalY);
|
||||
w.Write((byte)m.Surface);
|
||||
w.Write((byte)m.Deco);
|
||||
w.Write(m.Flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteWorldTileDeltas(BinaryWriter w, List<WorldTileDelta> tiles)
|
||||
{
|
||||
w.Write(tiles.Count);
|
||||
foreach (var t in tiles)
|
||||
{
|
||||
w.Write(t.X);
|
||||
w.Write(t.Y);
|
||||
w.Write(t.NewBiome);
|
||||
w.Write(t.NewFeatures);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteFlags(BinaryWriter w, Dictionary<string, int> flags)
|
||||
{
|
||||
w.Write(flags.Count);
|
||||
foreach (var kv in flags)
|
||||
{
|
||||
WriteString(w, kv.Key);
|
||||
w.Write(kv.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Body reader ───────────────────────────────────────────────────────
|
||||
|
||||
private static SaveBody ReadBody(BinaryReader r)
|
||||
{
|
||||
var body = new SaveBody();
|
||||
while (true)
|
||||
{
|
||||
byte tag = r.ReadByte();
|
||||
if (tag == TAG_END) break;
|
||||
uint len = r.ReadUInt32();
|
||||
byte[] sectionBytes = r.ReadBytes((int)len);
|
||||
if (sectionBytes.Length != len) throw new InvalidDataException("Save body truncated.");
|
||||
using var ms = new MemoryStream(sectionBytes);
|
||||
using var br = new BinaryReader(ms);
|
||||
switch (tag)
|
||||
{
|
||||
case TAG_PLAYER: body.Player = ReadPlayer(br); break;
|
||||
case TAG_CLOCK: body.Clock.InGameSeconds = br.ReadInt64(); break;
|
||||
case TAG_CHUNKS: body.ModifiedChunks = ReadChunkDeltas(br); break;
|
||||
case TAG_WTDELTA: body.ModifiedWorldTiles = ReadWorldTileDeltas(br); break;
|
||||
case TAG_FLAGS: body.Flags = ReadFlags(br); break;
|
||||
case TAG_CHARACTER: body.PlayerCharacter = ReadCharacter(br); break;
|
||||
case TAG_NPC_ROSTER: body.NpcRoster = ReadNpcRoster(br); break;
|
||||
case TAG_ENCOUNTER: body.ActiveEncounter = ReadEncounter(br); break;
|
||||
case TAG_FACTION_STANDINGS:
|
||||
body.ReputationState.FactionStandings = ReadFactionStandings(br);
|
||||
break;
|
||||
case TAG_REPUTATION:
|
||||
{
|
||||
var rep = ReadReputation(br);
|
||||
// Faction standings may have been read earlier — preserve them.
|
||||
rep.FactionStandings = body.ReputationState.FactionStandings;
|
||||
body.ReputationState = rep;
|
||||
break;
|
||||
}
|
||||
case TAG_QUESTS:
|
||||
body.QuestEngineState = ReadQuests(br);
|
||||
break;
|
||||
default: /* unknown tag: skip — forward compat */ break;
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private static PlayerActorState ReadPlayer(BinaryReader r)
|
||||
{
|
||||
var p = new PlayerActorState
|
||||
{
|
||||
Id = r.ReadInt32(),
|
||||
Name = ReadString(r),
|
||||
PositionX = r.ReadSingle(),
|
||||
PositionY = r.ReadSingle(),
|
||||
FacingAngleRad = r.ReadSingle(),
|
||||
SpeedWorldPxPerSec = r.ReadSingle(),
|
||||
HighestTierReached = r.ReadInt32(),
|
||||
};
|
||||
int n = r.ReadInt32();
|
||||
p.DiscoveredPoiIds = new int[n];
|
||||
for (int i = 0; i < n; i++) p.DiscoveredPoiIds[i] = r.ReadInt32();
|
||||
return p;
|
||||
}
|
||||
|
||||
private static Dictionary<ChunkCoord, ChunkDelta> ReadChunkDeltas(BinaryReader r)
|
||||
{
|
||||
var m = new Dictionary<ChunkCoord, ChunkDelta>();
|
||||
int n = r.ReadInt32();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int cx = r.ReadInt32();
|
||||
int cy = r.ReadInt32();
|
||||
var d = new ChunkDelta { SpawnsConsumed = r.ReadBoolean() };
|
||||
int mc = r.ReadInt32();
|
||||
for (int j = 0; j < mc; j++)
|
||||
{
|
||||
byte lx = r.ReadByte();
|
||||
byte ly = r.ReadByte();
|
||||
var sf = (TacticalSurface)r.ReadByte();
|
||||
var de = (TacticalDeco)r.ReadByte();
|
||||
byte fl = r.ReadByte();
|
||||
d.TileMods.Add(new TileMod(lx, ly, sf, de, fl));
|
||||
}
|
||||
m[new ChunkCoord(cx, cy)] = d;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private static List<WorldTileDelta> ReadWorldTileDeltas(BinaryReader r)
|
||||
{
|
||||
int n = r.ReadInt32();
|
||||
var l = new List<WorldTileDelta>(n);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
ushort x = r.ReadUInt16();
|
||||
ushort y = r.ReadUInt16();
|
||||
byte b = r.ReadByte();
|
||||
ushort f = r.ReadUInt16();
|
||||
l.Add(new WorldTileDelta(x, y, b, f));
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> ReadFlags(BinaryReader r)
|
||||
{
|
||||
int n = r.ReadInt32();
|
||||
var d = new Dictionary<string, int>(n);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
string k = ReadString(r);
|
||||
int v = r.ReadInt32();
|
||||
d[k] = v;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
private static void WriteString(BinaryWriter w, string s)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(s ?? "");
|
||||
w.Write(bytes.Length);
|
||||
w.Write(bytes);
|
||||
}
|
||||
|
||||
private static string ReadString(BinaryReader r)
|
||||
{
|
||||
int n = r.ReadInt32();
|
||||
return Encoding.UTF8.GetString(r.ReadBytes(n));
|
||||
}
|
||||
|
||||
// ── Phase 5: Character section (TAG_CHARACTER = 100) ─────────────────
|
||||
|
||||
private static void WriteCharacter(BinaryWriter w, PlayerCharacterState c)
|
||||
{
|
||||
WriteString(w, c.CladeId);
|
||||
WriteString(w, c.SpeciesId);
|
||||
WriteString(w, c.ClassId);
|
||||
WriteString(w, c.BackgroundId);
|
||||
w.Write(c.STR); w.Write(c.DEX); w.Write(c.CON); w.Write(c.INT); w.Write(c.WIS); w.Write(c.CHA);
|
||||
w.Write(c.Level);
|
||||
w.Write(c.Xp);
|
||||
w.Write(c.MaxHp);
|
||||
w.Write(c.CurrentHp);
|
||||
w.Write(c.ExhaustionLevel);
|
||||
w.Write(c.SkillProficiencies.Length);
|
||||
foreach (var s in c.SkillProficiencies) w.Write(s);
|
||||
w.Write(c.Conditions.Length);
|
||||
foreach (var x in c.Conditions) w.Write(x);
|
||||
w.Write(c.Inventory.Length);
|
||||
foreach (var it in c.Inventory)
|
||||
{
|
||||
WriteString(w, it.ItemId);
|
||||
w.Write(it.Qty);
|
||||
w.Write(it.Condition);
|
||||
w.Write(it.EquippedAt.HasValue);
|
||||
if (it.EquippedAt.HasValue) w.Write(it.EquippedAt.Value);
|
||||
}
|
||||
// Phase 5 M6 additions — appended at the end so v5 readers (without
|
||||
// these fields) can still load by short-reading the section.
|
||||
WriteString(w, c.FightingStyle);
|
||||
w.Write(c.RageUsesRemaining);
|
||||
// Phase 6 M3 — currency. Same EOS-check pattern: older saves
|
||||
// without it short-read this section.
|
||||
w.Write(c.CurrencyFang);
|
||||
// Phase 6.5 M0 — subclass + learned features + level-up history.
|
||||
WriteString(w, c.SubclassId);
|
||||
w.Write(c.LearnedFeatureIds.Length);
|
||||
foreach (var fid in c.LearnedFeatureIds) WriteString(w, fid);
|
||||
w.Write(c.LevelUpHistory.Length);
|
||||
foreach (var h in c.LevelUpHistory)
|
||||
{
|
||||
w.Write(h.Level);
|
||||
w.Write(h.HpGained);
|
||||
w.Write(h.HpWasAveraged);
|
||||
w.Write(h.HpHitDieResult);
|
||||
WriteString(w, h.SubclassChosen ?? "");
|
||||
w.Write(h.AsiKeys.Length);
|
||||
foreach (byte k in h.AsiKeys) w.Write(k);
|
||||
w.Write(h.AsiValues.Length);
|
||||
foreach (int v in h.AsiValues) w.Write(v);
|
||||
w.Write(h.FeaturesUnlocked.Length);
|
||||
foreach (var fid in h.FeaturesUnlocked) WriteString(w, fid);
|
||||
}
|
||||
// Phase 6.5 M4 — hybrid state (optional). EOS-check pattern: write
|
||||
// a presence byte (0/1), then the fields when present.
|
||||
// Phase 6.5 M5 appends ActiveMaskTier (also EOS-checked on read).
|
||||
bool hybridPresent = c.Hybrid is not null;
|
||||
w.Write(hybridPresent);
|
||||
if (hybridPresent)
|
||||
{
|
||||
var h = c.Hybrid!;
|
||||
WriteString(w, h.SireClade);
|
||||
WriteString(w, h.SireSpecies);
|
||||
WriteString(w, h.DamClade);
|
||||
WriteString(w, h.DamSpecies);
|
||||
w.Write(h.DominantParent);
|
||||
w.Write(h.PassingActive);
|
||||
w.Write(h.NpcsWhoKnow.Length);
|
||||
foreach (int npcId in h.NpcsWhoKnow) w.Write(npcId);
|
||||
// Phase 6.5 M5 — mask tier (single byte).
|
||||
w.Write(h.ActiveMaskTier);
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayerCharacterState ReadCharacter(BinaryReader r)
|
||||
{
|
||||
var c = new PlayerCharacterState
|
||||
{
|
||||
CladeId = ReadString(r),
|
||||
SpeciesId = ReadString(r),
|
||||
ClassId = ReadString(r),
|
||||
BackgroundId = ReadString(r),
|
||||
STR = r.ReadByte(), DEX = r.ReadByte(), CON = r.ReadByte(),
|
||||
INT = r.ReadByte(), WIS = r.ReadByte(), CHA = r.ReadByte(),
|
||||
Level = r.ReadInt32(),
|
||||
Xp = r.ReadInt32(),
|
||||
MaxHp = r.ReadInt32(),
|
||||
CurrentHp = r.ReadInt32(),
|
||||
ExhaustionLevel = r.ReadInt32(),
|
||||
};
|
||||
int nSkills = r.ReadInt32();
|
||||
c.SkillProficiencies = new byte[nSkills];
|
||||
for (int i = 0; i < nSkills; i++) c.SkillProficiencies[i] = r.ReadByte();
|
||||
int nConds = r.ReadInt32();
|
||||
c.Conditions = new byte[nConds];
|
||||
for (int i = 0; i < nConds; i++) c.Conditions[i] = r.ReadByte();
|
||||
int nInv = r.ReadInt32();
|
||||
c.Inventory = new InventoryItemState[nInv];
|
||||
for (int i = 0; i < nInv; i++)
|
||||
{
|
||||
var it = new InventoryItemState
|
||||
{
|
||||
ItemId = ReadString(r),
|
||||
Qty = r.ReadInt32(),
|
||||
Condition = r.ReadInt32(),
|
||||
};
|
||||
bool hasEquip = r.ReadBoolean();
|
||||
it.EquippedAt = hasEquip ? r.ReadByte() : null;
|
||||
c.Inventory[i] = it;
|
||||
}
|
||||
// Phase 5 M6 additions — present in saves written by M6+, absent in v5
|
||||
// saves. The section is length-prefixed so v5 saves stop here.
|
||||
if (r.BaseStream.Position < r.BaseStream.Length)
|
||||
{
|
||||
c.FightingStyle = ReadString(r);
|
||||
c.RageUsesRemaining = r.ReadInt32();
|
||||
}
|
||||
// Phase 6 M3 addition — currency. Saves without it short-read here.
|
||||
if (r.BaseStream.Position < r.BaseStream.Length)
|
||||
{
|
||||
c.CurrencyFang = r.ReadInt32();
|
||||
}
|
||||
// Phase 6.5 M0 additions — subclass / features / level-up history.
|
||||
// Same EOS-check pattern: v6 saves without these fields short-read.
|
||||
if (r.BaseStream.Position < r.BaseStream.Length)
|
||||
{
|
||||
c.SubclassId = ReadString(r);
|
||||
int nFeatures = r.ReadInt32();
|
||||
var features = new string[nFeatures];
|
||||
for (int i = 0; i < nFeatures; i++) features[i] = ReadString(r);
|
||||
c.LearnedFeatureIds = features;
|
||||
int nHistory = r.ReadInt32();
|
||||
var history = new LevelUpRecordState[nHistory];
|
||||
for (int i = 0; i < nHistory; i++)
|
||||
{
|
||||
var h = new LevelUpRecordState
|
||||
{
|
||||
Level = r.ReadInt32(),
|
||||
HpGained = r.ReadInt32(),
|
||||
HpWasAveraged = r.ReadBoolean(),
|
||||
HpHitDieResult = r.ReadInt32(),
|
||||
SubclassChosen = ReadString(r),
|
||||
};
|
||||
int nKeys = r.ReadInt32();
|
||||
var keys = new byte[nKeys];
|
||||
for (int j = 0; j < nKeys; j++) keys[j] = r.ReadByte();
|
||||
h.AsiKeys = keys;
|
||||
int nVals = r.ReadInt32();
|
||||
var vals = new int[nVals];
|
||||
for (int j = 0; j < nVals; j++) vals[j] = r.ReadInt32();
|
||||
h.AsiValues = vals;
|
||||
int nFids = r.ReadInt32();
|
||||
var fids = new string[nFids];
|
||||
for (int j = 0; j < nFids; j++) fids[j] = ReadString(r);
|
||||
h.FeaturesUnlocked = fids;
|
||||
history[i] = h;
|
||||
}
|
||||
c.LevelUpHistory = history;
|
||||
}
|
||||
// Phase 6.5 M4 — hybrid state (optional, EOS-checked).
|
||||
if (r.BaseStream.Position < r.BaseStream.Length)
|
||||
{
|
||||
bool hybridPresent = r.ReadBoolean();
|
||||
if (hybridPresent)
|
||||
{
|
||||
var h = new HybridStateSnapshot
|
||||
{
|
||||
SireClade = ReadString(r),
|
||||
SireSpecies = ReadString(r),
|
||||
DamClade = ReadString(r),
|
||||
DamSpecies = ReadString(r),
|
||||
DominantParent = r.ReadByte(),
|
||||
PassingActive = r.ReadBoolean(),
|
||||
};
|
||||
int nKnow = r.ReadInt32();
|
||||
var knowList = new int[nKnow];
|
||||
for (int i = 0; i < nKnow; i++) knowList[i] = r.ReadInt32();
|
||||
h.NpcsWhoKnow = knowList;
|
||||
// Phase 6.5 M5 — mask tier (EOS-checked).
|
||||
if (r.BaseStream.Position < r.BaseStream.Length)
|
||||
h.ActiveMaskTier = r.ReadByte();
|
||||
c.Hybrid = h;
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Phase 5 M5: NPC roster + Encounter sections ───────────────────────
|
||||
|
||||
private static void WriteNpcRoster(BinaryWriter w, NpcRosterState roster)
|
||||
{
|
||||
w.Write(roster.ChunkDeltas.Count);
|
||||
foreach (var d in roster.ChunkDeltas)
|
||||
{
|
||||
w.Write(d.ChunkX);
|
||||
w.Write(d.ChunkY);
|
||||
w.Write(d.KilledSpawnIndices.Length);
|
||||
foreach (int idx in d.KilledSpawnIndices) w.Write(idx);
|
||||
}
|
||||
}
|
||||
|
||||
private static NpcRosterState ReadNpcRoster(BinaryReader r)
|
||||
{
|
||||
var roster = new NpcRosterState();
|
||||
int n = r.ReadInt32();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var d = new NpcChunkDelta { ChunkX = r.ReadInt32(), ChunkY = r.ReadInt32() };
|
||||
int m = r.ReadInt32();
|
||||
d.KilledSpawnIndices = new int[m];
|
||||
for (int j = 0; j < m; j++) d.KilledSpawnIndices[j] = r.ReadInt32();
|
||||
roster.ChunkDeltas.Add(d);
|
||||
}
|
||||
return roster;
|
||||
}
|
||||
|
||||
private static void WriteEncounter(BinaryWriter w, EncounterState enc)
|
||||
{
|
||||
w.Write(enc.EncounterId);
|
||||
w.Write(enc.RollCount);
|
||||
w.Write(enc.CurrentTurnIndex);
|
||||
w.Write(enc.RoundNumber);
|
||||
w.Write(enc.InitiativeOrder.Length);
|
||||
foreach (int i in enc.InitiativeOrder) w.Write(i);
|
||||
w.Write(enc.Combatants.Length);
|
||||
foreach (var c in enc.Combatants)
|
||||
{
|
||||
w.Write(c.Id);
|
||||
WriteString(w, c.Name);
|
||||
w.Write(c.IsPlayer);
|
||||
w.Write(c.NpcChunkX.HasValue);
|
||||
if (c.NpcChunkX.HasValue) w.Write(c.NpcChunkX.Value);
|
||||
w.Write(c.NpcChunkY.HasValue);
|
||||
if (c.NpcChunkY.HasValue) w.Write(c.NpcChunkY.Value);
|
||||
w.Write(c.NpcSpawnIndex.HasValue);
|
||||
if (c.NpcSpawnIndex.HasValue) w.Write(c.NpcSpawnIndex.Value);
|
||||
WriteString(w, c.NpcTemplateId);
|
||||
w.Write(c.CurrentHp);
|
||||
w.Write(c.PositionX);
|
||||
w.Write(c.PositionY);
|
||||
w.Write(c.Conditions.Length);
|
||||
foreach (byte cb in c.Conditions) w.Write(cb);
|
||||
}
|
||||
}
|
||||
|
||||
private static EncounterState ReadEncounter(BinaryReader r)
|
||||
{
|
||||
var s = new EncounterState
|
||||
{
|
||||
EncounterId = r.ReadUInt64(),
|
||||
RollCount = r.ReadInt32(),
|
||||
CurrentTurnIndex = r.ReadInt32(),
|
||||
RoundNumber = r.ReadInt32(),
|
||||
};
|
||||
int n = r.ReadInt32();
|
||||
s.InitiativeOrder = new int[n];
|
||||
for (int i = 0; i < n; i++) s.InitiativeOrder[i] = r.ReadInt32();
|
||||
int m = r.ReadInt32();
|
||||
s.Combatants = new CombatantSnapshot[m];
|
||||
for (int i = 0; i < m; i++)
|
||||
{
|
||||
var c = new CombatantSnapshot
|
||||
{
|
||||
Id = r.ReadInt32(),
|
||||
Name = ReadString(r),
|
||||
IsPlayer = r.ReadBoolean(),
|
||||
};
|
||||
if (r.ReadBoolean()) c.NpcChunkX = r.ReadInt32();
|
||||
if (r.ReadBoolean()) c.NpcChunkY = r.ReadInt32();
|
||||
if (r.ReadBoolean()) c.NpcSpawnIndex = r.ReadInt32();
|
||||
c.NpcTemplateId = ReadString(r);
|
||||
c.CurrentHp = r.ReadInt32();
|
||||
c.PositionX = r.ReadSingle();
|
||||
c.PositionY = r.ReadSingle();
|
||||
int cn = r.ReadInt32();
|
||||
c.Conditions = new byte[cn];
|
||||
for (int j = 0; j < cn; j++) c.Conditions[j] = r.ReadByte();
|
||||
s.Combatants[i] = c;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── Phase 5: Schema-version compatibility ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this binary can load the supplied header's save data.
|
||||
/// Phase 5 refuses any save with version < <see cref="C.SAVE_SCHEMA_MIN_VERSION"/>
|
||||
/// (i.e. Phase-4 saves with no character data).
|
||||
/// </summary>
|
||||
public static bool IsCompatible(SaveHeader header) =>
|
||||
header.Version >= C.SAVE_SCHEMA_MIN_VERSION;
|
||||
|
||||
/// <summary>Human-readable reason a save is incompatible. Empty when compatible.</summary>
|
||||
public static string IncompatibilityReason(SaveHeader header)
|
||||
{
|
||||
if (header.Version < C.SAVE_SCHEMA_MIN_VERSION)
|
||||
return $"This save is from schema v{header.Version}; minimum supported is v{C.SAVE_SCHEMA_MIN_VERSION}. " +
|
||||
"Start a new game from the same seed to play in Phase 5.";
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── Phase 6 M2 — reputation ──────────────────────────────────────────
|
||||
|
||||
private static void WriteFactionStandings(BinaryWriter w, Dictionary<string, int> standings)
|
||||
{
|
||||
w.Write(standings.Count);
|
||||
foreach (var kv in standings)
|
||||
{
|
||||
WriteString(w, kv.Key);
|
||||
w.Write(kv.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> ReadFactionStandings(BinaryReader r)
|
||||
{
|
||||
int n = r.ReadInt32();
|
||||
var d = new Dictionary<string, int>(n, StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
string k = ReadString(r);
|
||||
int v = r.ReadInt32();
|
||||
d[k] = v;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
private static void WriteReputation(BinaryWriter w, ReputationSnapshot rep)
|
||||
{
|
||||
// Personal records.
|
||||
w.Write(rep.Personal.Count);
|
||||
foreach (var p in rep.Personal)
|
||||
{
|
||||
WriteString(w, p.RoleTag);
|
||||
w.Write(p.Score);
|
||||
w.Write(p.Trust);
|
||||
w.Write(p.Betrayed);
|
||||
w.Write(p.LastInteractionSeconds);
|
||||
w.Write(p.MemoryTags.Length);
|
||||
foreach (var m in p.MemoryTags) WriteString(w, m);
|
||||
w.Write(p.Log.Length);
|
||||
foreach (var ev in p.Log) WriteRepEvent(w, ev);
|
||||
}
|
||||
// Ledger.
|
||||
w.Write(rep.Ledger.Count);
|
||||
foreach (var ev in rep.Ledger) WriteRepEvent(w, ev);
|
||||
}
|
||||
|
||||
private static ReputationSnapshot ReadReputation(BinaryReader r)
|
||||
{
|
||||
var rep = new ReputationSnapshot();
|
||||
int n = r.ReadInt32();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var p = new PersonalDispositionSnapshot
|
||||
{
|
||||
RoleTag = ReadString(r),
|
||||
Score = r.ReadInt32(),
|
||||
Trust = r.ReadByte(),
|
||||
Betrayed = r.ReadBoolean(),
|
||||
LastInteractionSeconds = r.ReadInt64(),
|
||||
};
|
||||
int mtags = r.ReadInt32();
|
||||
var memory = new string[mtags];
|
||||
for (int j = 0; j < mtags; j++) memory[j] = ReadString(r);
|
||||
p.MemoryTags = memory;
|
||||
int logCount = r.ReadInt32();
|
||||
var log = new RepEventSnapshot[logCount];
|
||||
for (int j = 0; j < logCount; j++) log[j] = ReadRepEvent(r);
|
||||
p.Log = log;
|
||||
rep.Personal.Add(p);
|
||||
}
|
||||
int ledgerCount = r.ReadInt32();
|
||||
for (int i = 0; i < ledgerCount; i++) rep.Ledger.Add(ReadRepEvent(r));
|
||||
return rep;
|
||||
}
|
||||
|
||||
private static void WriteRepEvent(BinaryWriter w, RepEventSnapshot ev)
|
||||
{
|
||||
w.Write(ev.SequenceId);
|
||||
w.Write(ev.Kind);
|
||||
WriteString(w, ev.FactionId);
|
||||
WriteString(w, ev.RoleTag);
|
||||
w.Write(ev.Magnitude);
|
||||
WriteString(w, ev.Note);
|
||||
w.Write(ev.OriginTileX);
|
||||
w.Write(ev.OriginTileY);
|
||||
w.Write(ev.TimestampSeconds);
|
||||
}
|
||||
|
||||
private static RepEventSnapshot ReadRepEvent(BinaryReader r) => new()
|
||||
{
|
||||
SequenceId = r.ReadInt32(),
|
||||
Kind = r.ReadByte(),
|
||||
FactionId = ReadString(r),
|
||||
RoleTag = ReadString(r),
|
||||
Magnitude = r.ReadInt32(),
|
||||
Note = ReadString(r),
|
||||
OriginTileX = r.ReadInt32(),
|
||||
OriginTileY = r.ReadInt32(),
|
||||
TimestampSeconds = r.ReadInt64(),
|
||||
};
|
||||
|
||||
// ── Phase 6 M4 — quest engine ────────────────────────────────────────
|
||||
|
||||
private static void WriteQuests(BinaryWriter w, QuestSnapshot snap)
|
||||
{
|
||||
w.Write(snap.Active.Count);
|
||||
foreach (var s in snap.Active) WriteQuestState(w, s);
|
||||
w.Write(snap.Completed.Count);
|
||||
foreach (var s in snap.Completed) WriteQuestState(w, s);
|
||||
w.Write(snap.Journal.Count);
|
||||
foreach (var line in snap.Journal) WriteString(w, line);
|
||||
}
|
||||
|
||||
private static QuestSnapshot ReadQuests(BinaryReader r)
|
||||
{
|
||||
var snap = new QuestSnapshot();
|
||||
int activeCount = r.ReadInt32();
|
||||
for (int i = 0; i < activeCount; i++) snap.Active.Add(ReadQuestState(r));
|
||||
int completedCount = r.ReadInt32();
|
||||
for (int i = 0; i < completedCount; i++) snap.Completed.Add(ReadQuestState(r));
|
||||
int journalCount = r.ReadInt32();
|
||||
for (int i = 0; i < journalCount; i++) snap.Journal.Add(ReadString(r));
|
||||
return snap;
|
||||
}
|
||||
|
||||
private static void WriteQuestState(BinaryWriter w, QuestStateSnapshot s)
|
||||
{
|
||||
WriteString(w, s.QuestId);
|
||||
WriteString(w, s.CurrentStep);
|
||||
w.Write(s.Status);
|
||||
w.Write(s.StartedAt);
|
||||
w.Write(s.StepStartedAt);
|
||||
w.Write(s.JournalLines.Length);
|
||||
foreach (var line in s.JournalLines) WriteString(w, line);
|
||||
}
|
||||
|
||||
private static QuestStateSnapshot ReadQuestState(BinaryReader r)
|
||||
{
|
||||
var s = new QuestStateSnapshot
|
||||
{
|
||||
QuestId = ReadString(r),
|
||||
CurrentStep = ReadString(r),
|
||||
Status = r.ReadByte(),
|
||||
StartedAt = r.ReadInt64(),
|
||||
StepStartedAt = r.ReadInt64(),
|
||||
};
|
||||
int n = r.ReadInt32();
|
||||
var lines = new string[n];
|
||||
for (int i = 0; i < n; i++) lines[i] = ReadString(r);
|
||||
s.JournalLines = lines;
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Theriapolis.Core.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// JSON-serializable metadata stored at the front of a save file. Designed so
|
||||
/// the slot picker can deserialize just the header (with a 4-byte length
|
||||
/// prefix preceding it) without touching the binary body.
|
||||
/// </summary>
|
||||
public sealed class SaveHeader
|
||||
{
|
||||
/// <summary>Schema version. Bump on breaking changes — see SaveMigrations.</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; } = C.SAVE_SCHEMA_VERSION;
|
||||
|
||||
[JsonPropertyName("worldSeed")]
|
||||
public string WorldSeedHex { get; set; } = "0x0";
|
||||
|
||||
[JsonPropertyName("stageHashes")]
|
||||
public Dictionary<string, string> StageHashes { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("playerName")]
|
||||
public string PlayerName { get; set; } = "Wanderer";
|
||||
|
||||
[JsonPropertyName("playerTier")]
|
||||
public int PlayerTier { get; set; }
|
||||
|
||||
[JsonPropertyName("inGameSeconds")]
|
||||
public long InGameSeconds { get; set; }
|
||||
|
||||
[JsonPropertyName("savedAt")]
|
||||
public string SavedAtUtc { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("appVersion")]
|
||||
public string AppVersion { get; set; } = "0.4.0";
|
||||
|
||||
/// <summary>Convenience: a one-line label for the slot picker.</summary>
|
||||
public string SlotLabel()
|
||||
{
|
||||
// "Wanderer — Y0 Spring D5 (Tier 1)" style
|
||||
long sec = InGameSeconds;
|
||||
long days = sec / Time.WorldClock.SecondsPerDay;
|
||||
int year = (int)(days / Time.WorldClock.DaysPerYear);
|
||||
var season = (Time.Season)((days / Time.WorldClock.DaysPerSeason) % 4);
|
||||
long dayOfSeason = days % Time.WorldClock.DaysPerSeason;
|
||||
return $"{PlayerName} — Y{year} {season} D{dayOfSeason} (Tier {PlayerTier})";
|
||||
}
|
||||
|
||||
public ulong ParseSeed()
|
||||
{
|
||||
string s = WorldSeedHex.Trim();
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
return Convert.ToUInt64(s[2..], 16);
|
||||
return ulong.Parse(s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Theriapolis.Core.Persistence.SaveMigrations;
|
||||
|
||||
/// <summary>
|
||||
/// Single-step migration from one schema version to the next. Migrations
|
||||
/// chain — Migrations.MigrateUp finds a path from header.Version to
|
||||
/// C.SAVE_SCHEMA_VERSION and applies each step in order.
|
||||
/// </summary>
|
||||
public interface ISaveMigration
|
||||
{
|
||||
int FromVersion { get; }
|
||||
int ToVersion { get; }
|
||||
void Apply(SaveHeader header, SaveBody body);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace Theriapolis.Core.Persistence.SaveMigrations;
|
||||
|
||||
/// <summary>
|
||||
/// Registry + chain runner for SaveBody migrations. Phase 4 is the v4 baseline;
|
||||
/// older versions are not supported because nothing earlier shipped. As we
|
||||
/// bump the schema in Phase 5+, register migrations here.
|
||||
///
|
||||
/// The registry seeds itself with every built-in migration on first use, so
|
||||
/// callers don't need to remember to <see cref="Register"/> them at startup.
|
||||
/// </summary>
|
||||
public static class Migrations
|
||||
{
|
||||
private static readonly List<ISaveMigration> _registry = new();
|
||||
private static bool _seeded;
|
||||
|
||||
public static void Register(ISaveMigration m)
|
||||
{
|
||||
EnsureSeeded();
|
||||
_registry.Add(m);
|
||||
}
|
||||
|
||||
private static void EnsureSeeded()
|
||||
{
|
||||
if (_seeded) return;
|
||||
_seeded = true;
|
||||
// Built-in migrations registered in version order:
|
||||
_registry.Add(new V5ToV6Migration());
|
||||
_registry.Add(new V6ToV7Migration());
|
||||
_registry.Add(new V7ToV8Migration());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk migrations from header.Version up to C.SAVE_SCHEMA_VERSION. Returns
|
||||
/// false if no chain exists; the caller decides whether to hard-block or
|
||||
/// best-effort the load (see SaveCodec docs).
|
||||
/// </summary>
|
||||
public static bool MigrateUp(SaveHeader header, SaveBody body)
|
||||
{
|
||||
EnsureSeeded();
|
||||
while (header.Version < C.SAVE_SCHEMA_VERSION)
|
||||
{
|
||||
var step = _registry.FirstOrDefault(m => m.FromVersion == header.Version);
|
||||
if (step is null) return false;
|
||||
step.Apply(header, body);
|
||||
header.Version = step.ToVersion;
|
||||
}
|
||||
return header.Version == C.SAVE_SCHEMA_VERSION;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Theriapolis.Core.Persistence.SaveMigrations;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M2 — additive migration from save schema v5 (Phase 5 ship) to
|
||||
/// v6 (Phase 6 reputation core). Non-destructive: every v5 field carries
|
||||
/// over unchanged. The new <see cref="SaveBody.ReputationState"/> is
|
||||
/// already initialised to a fresh empty <see cref="ReputationSnapshot"/>
|
||||
/// by the SaveBody constructor, so this migration just bumps the header
|
||||
/// version.
|
||||
///
|
||||
/// Phase 5's placeholder <see cref="SaveBody.Factions"/> and
|
||||
/// <see cref="SaveBody.Reputation"/> dictionaries were never populated
|
||||
/// (Phase 5 didn't ship the reputation system), so we don't need to
|
||||
/// translate any data — they stay empty and ignored.
|
||||
/// </summary>
|
||||
public sealed class V5ToV6Migration : ISaveMigration
|
||||
{
|
||||
public int FromVersion => 5;
|
||||
public int ToVersion => 6;
|
||||
|
||||
public void Apply(SaveHeader header, SaveBody body)
|
||||
{
|
||||
// Body fields all default-initialise to empty in SaveBody — the
|
||||
// ReputationState is already a fresh ReputationSnapshot. Phase-5
|
||||
// saves had nothing to translate, so this is a pure version bump.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Theriapolis.Core.Persistence.SaveMigrations;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M0 — additive migration from save schema v6 (Phase 6 ship) to
|
||||
/// v7 (Phase 6.5 levelling). Non-destructive: every v6 field carries over
|
||||
/// unchanged. The new <see cref="PlayerCharacterState.SubclassId"/>,
|
||||
/// <see cref="PlayerCharacterState.LearnedFeatureIds"/>, and
|
||||
/// <see cref="PlayerCharacterState.LevelUpHistory"/> default-initialise
|
||||
/// to empty values by the constructor, so this migration just bumps the
|
||||
/// header version.
|
||||
///
|
||||
/// Phase-6 saves had no level-up history (every character stayed at level
|
||||
/// 1, Xp = 0). On load they continue to be valid level-1 characters; the
|
||||
/// player can immediately start earning XP and levelling up under the new
|
||||
/// rules.
|
||||
/// </summary>
|
||||
public sealed class V6ToV7Migration : ISaveMigration
|
||||
{
|
||||
public int FromVersion => 6;
|
||||
public int ToVersion => 7;
|
||||
|
||||
public void Apply(SaveHeader header, SaveBody body)
|
||||
{
|
||||
// No data translation needed. PlayerCharacterState's new fields
|
||||
// (SubclassId, LearnedFeatureIds, LevelUpHistory) default-initialise
|
||||
// to empty in their record, and SaveCodec.ReadCharacter handles
|
||||
// missing-section bytes via the EOS-check pattern Phase 5 already
|
||||
// established. Pure version bump.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Theriapolis.Core.Persistence.SaveMigrations;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M0 — additive migration from save schema v7 (Phase 6.5 ship) to
|
||||
/// v8 (Phase 7 dungeons). Non-destructive: every v7 field carries over
|
||||
/// unchanged. Phase 7 reserves three new save sections — anchors, building
|
||||
/// deltas, and per-PoI dungeon state — but ships M0 with the version bump
|
||||
/// only; the fields default-initialise to empty and a v7 save loads as
|
||||
/// "no anchors persisted, no buildings modified, no dungeons visited"
|
||||
/// which is the truth for any pre-Phase-7 save.
|
||||
///
|
||||
/// Subsequent Phase 7 milestones (M1+) will populate these sections;
|
||||
/// the migration stays additive throughout.
|
||||
/// </summary>
|
||||
public sealed class V7ToV8Migration : ISaveMigration
|
||||
{
|
||||
public int FromVersion => 7;
|
||||
public int ToVersion => 8;
|
||||
|
||||
public void Apply(SaveHeader header, SaveBody body)
|
||||
{
|
||||
// No data translation needed. Phase 7's new save sections
|
||||
// (TAG_ANCHORS / TAG_BUILDINGS / TAG_DUNGEONS) default to empty in
|
||||
// SaveBody and SaveCodec uses the established EOS-check pattern
|
||||
// for additive sections. Pure version bump.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Theriapolis.Core.Rules.Character;
|
||||
|
||||
/// <summary>
|
||||
/// Whose side an actor is on at the moment. Drives encounter-trigger logic:
|
||||
/// hostile → auto-trigger combat on LOS; friendly/neutral → "[F] Talk to ..."
|
||||
/// prompt. Phase 5 sets this from <see cref="Data.NpcTemplateDef.DefaultAllegiance"/>;
|
||||
/// faction logic in Phase 6 may mutate it at runtime.
|
||||
/// </summary>
|
||||
public enum Allegiance : byte
|
||||
{
|
||||
Player = 0,
|
||||
Allied = 1,
|
||||
Neutral = 2,
|
||||
Friendly = 3,
|
||||
Hostile = 4,
|
||||
}
|
||||
|
||||
public static class AllegianceExtensions
|
||||
{
|
||||
public static Allegiance FromJson(string raw) => raw.ToLowerInvariant() switch
|
||||
{
|
||||
"player" => Allegiance.Player,
|
||||
"allied" => Allegiance.Allied,
|
||||
"neutral" => Allegiance.Neutral,
|
||||
"friendly" => Allegiance.Friendly,
|
||||
"hostile" => Allegiance.Hostile,
|
||||
_ => throw new ArgumentException($"Unknown allegiance: '{raw}'"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Character;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime aggregate of everything that makes a creature a *character* —
|
||||
/// stats, class, clade, species, background, inventory, HP, conditions, level,
|
||||
/// XP. Composed onto an <see cref="Entities.Actor"/> via the
|
||||
/// <c>Actor.Character</c> field; the actor handles position and rendering,
|
||||
/// the character handles gameplay state.
|
||||
///
|
||||
/// Phase 5 M2 builds these via <see cref="CharacterBuilder"/> at character
|
||||
/// creation. Saved snapshots round-trip through
|
||||
/// <see cref="Persistence.PlayerCharacterState"/>.
|
||||
/// </summary>
|
||||
public sealed class Character
|
||||
{
|
||||
public CladeDef Clade { get; }
|
||||
public SpeciesDef Species { get; }
|
||||
public ClassDef ClassDef { get; }
|
||||
public BackgroundDef Background { get; }
|
||||
public AbilityScores Abilities { get; private set; }
|
||||
|
||||
public int Level { get; set; } = 1;
|
||||
public int Xp { get; set; } = 0;
|
||||
public int MaxHp { get; set; }
|
||||
public int CurrentHp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M0 — subclass id, set when the level-3 selection is made.
|
||||
/// Empty pre-L3; references one of <see cref="ClassDef.SubclassIds"/> after.
|
||||
/// Loaded by save round-trip.
|
||||
/// </summary>
|
||||
public string SubclassId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M0 — feature ids learned across all level-ups, in unlock
|
||||
/// order. Includes level-1 features applied by <see cref="CharacterBuilder"/>
|
||||
/// plus everything <see cref="ApplyLevelUp"/> appends. The
|
||||
/// <see cref="FeatureProcessor"/> consults this list when resolving combat
|
||||
/// effects, dialogue hooks, etc.
|
||||
/// </summary>
|
||||
public List<string> LearnedFeatureIds { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M0 — append-only history of per-level deltas. Used by
|
||||
/// the level-up screen for display and by save round-trip for
|
||||
/// reproducibility. Index = level - 1 (so [0] is the level-1 entry,
|
||||
/// [1] is the level-2 entry, etc.); element 0 is synthesized at
|
||||
/// character creation.
|
||||
/// </summary>
|
||||
public List<LevelUpRecord> LevelUpHistory { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M4 — hybrid-character state. Null for purebred PCs
|
||||
/// (the common case); non-null indicates the PC was built via
|
||||
/// <see cref="CharacterBuilder.TryBuildHybrid"/>. Universal hybrid
|
||||
/// detriments (Scent Dysphoria, Social Stigma, Illegible Body
|
||||
/// Language, Medical Incompatibility) are inherent and applied via
|
||||
/// <see cref="HybridDetriments"/> at use sites.
|
||||
/// </summary>
|
||||
public HybridState? Hybrid { get; set; }
|
||||
|
||||
/// <summary>True when the PC is a hybrid (i.e. <see cref="Hybrid"/> non-null).</summary>
|
||||
public bool IsHybrid => Hybrid is not null;
|
||||
|
||||
/// <summary>Skill proficiencies — class skills + background skills, deduplicated.</summary>
|
||||
public HashSet<SkillId> SkillProficiencies { get; } = new();
|
||||
|
||||
public Inventory Inventory { get; } = new();
|
||||
|
||||
/// <summary>Conditions currently affecting the character. Phase 5 M5 wires durations.</summary>
|
||||
public HashSet<Condition> Conditions { get; } = new();
|
||||
|
||||
/// <summary>Exhaustion level 0..6, separate from <see cref="Conditions"/> (binary flags).</summary>
|
||||
public int ExhaustionLevel { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M6: chosen Fangsworn fighting style ("duelist", "great_weapon",
|
||||
/// "shieldwall", "fang_and_blade", "natural_predator"). Empty for non-Fangsworn
|
||||
/// or when not yet picked. Defaults to "duelist" via CharacterBuilder.
|
||||
/// </summary>
|
||||
public string FightingStyle { get; set; } = "";
|
||||
|
||||
/// <summary>Phase 5 M6: Feral Rage uses remaining (refills on long rest; M6 treats as per-encounter).</summary>
|
||||
public int RageUsesRemaining { get; set; } = 2;
|
||||
|
||||
// ── Phase 6.5 M1: per-encounter resource pools ────────────────────────
|
||||
/// <summary>
|
||||
/// Muzzle-Speaker Vocalization Dice — uses remaining (long-rest, 4 default
|
||||
/// at level 1). Refilled per encounter at M1 since the rest model lives
|
||||
/// in Phase 8.
|
||||
/// </summary>
|
||||
public int VocalizationDiceRemaining { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Covenant-Keeper Lay on Paws — HP pool remaining. Recharges to
|
||||
/// <c>5 × CHA</c> on long rest; M1 refills per encounter.
|
||||
/// </summary>
|
||||
public int LayOnPawsPoolRemaining { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Claw-Wright Field Repair — uses remaining. Once per short rest at L1
|
||||
/// per the JSON; M1 treats as 1 per encounter.
|
||||
/// </summary>
|
||||
public int FieldRepairUsesRemaining { get; set; } = 1;
|
||||
|
||||
// ── Phase 6.5 M3: ability-stream resource pools ──────────────────────
|
||||
/// <summary>
|
||||
/// Scent-Broker Pheromone Craft — uses remaining. The JSON ladder
|
||||
/// (<c>pheromone_craft_2/3/4/5</c> at L2/L5/L9/L13) sets the per-rest
|
||||
/// cap; <see cref="Combat.FeatureProcessor.EnsurePheromoneUsesReady"/>
|
||||
/// tops the pool up to that cap at encounter start.
|
||||
/// </summary>
|
||||
public int PheromoneUsesRemaining { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Covenant-Keeper Covenant's Authority — uses remaining. The JSON
|
||||
/// ladder (<c>covenants_authority_2/3/4/5</c> at L2/L9/L13/L17) sets
|
||||
/// the cap; M3 tops up per encounter.
|
||||
/// </summary>
|
||||
public int CovenantAuthorityUsesRemaining { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M3 — Fangs (Theriapolis's universal coin). Used by the shop
|
||||
/// dialogue branch. Defaults to a small starting stipend so the very
|
||||
/// first merchant interaction has something to buy with.
|
||||
/// </summary>
|
||||
public int CurrencyFang { get; set; } = 25;
|
||||
|
||||
public bool IsAlive => CurrentHp > 0 || Conditions.Contains(Condition.Unconscious);
|
||||
|
||||
public Character(
|
||||
CladeDef clade,
|
||||
SpeciesDef species,
|
||||
ClassDef classDef,
|
||||
BackgroundDef background,
|
||||
AbilityScores abilities)
|
||||
{
|
||||
Clade = clade ?? throw new ArgumentNullException(nameof(clade));
|
||||
Species = species ?? throw new ArgumentNullException(nameof(species));
|
||||
ClassDef = classDef ?? throw new ArgumentNullException(nameof(classDef));
|
||||
Background = background ?? throw new ArgumentNullException(nameof(background));
|
||||
Abilities = abilities;
|
||||
}
|
||||
|
||||
/// <summary>Replace ability scores wholesale (used during creation; combat doesn't mutate this).</summary>
|
||||
public void SetAbilities(AbilityScores scores)
|
||||
{
|
||||
Abilities = scores;
|
||||
}
|
||||
|
||||
/// <summary>Body size category, derived from species.</summary>
|
||||
public SizeCategory Size => SizeExtensions.FromJson(Species.Size);
|
||||
|
||||
/// <summary>d20 proficiency bonus for the character's current level.</summary>
|
||||
public int ProficiencyBonus => Stats.ProficiencyBonus.ForLevel(Level);
|
||||
|
||||
/// <summary>
|
||||
/// Computes max HP from class hit die + CON modifier at level 1 (and
|
||||
/// avg-rounded-up + CON for each level beyond, per d20 default).
|
||||
/// Phase 6.5 M0: still useful for character-creation initial HP, but
|
||||
/// real per-level HP gains come from <see cref="ApplyLevelUp"/>'s
|
||||
/// <see cref="LevelUpResult.HpGained"/> deltas (which respect the
|
||||
/// player's roll-vs-average choice and pin to a deterministic seed).
|
||||
/// </summary>
|
||||
public int ComputeMaxHpFromScratch()
|
||||
{
|
||||
int conMod = Abilities.ModFor(AbilityId.CON);
|
||||
int hp = ClassDef.HitDie + conMod;
|
||||
// Levels 2+ add avg-rounded-up + CON each. Phase 5 = level 1 only,
|
||||
// but the formula stays correct in case external code passes Level > 1.
|
||||
for (int lv = 2; lv <= Level; lv++)
|
||||
{
|
||||
int avgRoundedUp = (ClassDef.HitDie / 2) + 1;
|
||||
hp += avgRoundedUp + conMod;
|
||||
}
|
||||
return Math.Max(1, hp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M0 — apply a previously-computed <see cref="LevelUpResult"/>
|
||||
/// plus the player's <see cref="LevelUpChoices"/> to this character.
|
||||
/// Mutates Level, MaxHp, CurrentHp, SubclassId, Abilities, and appends
|
||||
/// the unlocked features to <see cref="LearnedFeatureIds"/>. Records
|
||||
/// the event in <see cref="LevelUpHistory"/>.
|
||||
///
|
||||
/// The caller is responsible for verifying the choices are valid for
|
||||
/// the result's open slots (subclass selected when GrantsSubclassChoice;
|
||||
/// ASI sums to +2 when GrantsAsiChoice). Validation lives in
|
||||
/// <see cref="LevelUpChoicesValidator"/>; this method trusts what it
|
||||
/// gets so it can be called from tests with bare choices.
|
||||
/// </summary>
|
||||
public void ApplyLevelUp(LevelUpResult result, LevelUpChoices choices)
|
||||
{
|
||||
if (result is null) throw new ArgumentNullException(nameof(result));
|
||||
if (choices is null) throw new ArgumentNullException(nameof(choices));
|
||||
|
||||
// Apply ASI (if any).
|
||||
if (result.GrantsAsiChoice && choices.AsiAdjustments.Count > 0)
|
||||
{
|
||||
var newAbilities = Abilities;
|
||||
foreach (var (ability, delta) in choices.AsiAdjustments)
|
||||
{
|
||||
int current = newAbilities.Get(ability);
|
||||
int cap = result.NewLevel >= C.CHARACTER_LEVEL_MAX
|
||||
? C.ABILITY_SCORE_CAP_AT_L20
|
||||
: C.ABILITY_SCORE_CAP_PRE_L20;
|
||||
int next = Math.Min(cap, current + delta);
|
||||
newAbilities = newAbilities.With(ability, next);
|
||||
}
|
||||
Abilities = newAbilities;
|
||||
}
|
||||
|
||||
// Subclass selection (if any).
|
||||
if (result.GrantsSubclassChoice && !string.IsNullOrEmpty(choices.SubclassId))
|
||||
{
|
||||
SubclassId = choices.SubclassId!;
|
||||
}
|
||||
|
||||
// HP. The result's HpGained already incorporates CON mod at compute
|
||||
// time; if the player took CON via ASI on the same level-up, we use
|
||||
// the *new* CON for HP gained. Recompute defensively.
|
||||
int conMod = Abilities.ModFor(AbilityId.CON);
|
||||
int hpGained;
|
||||
if (result.HpWasAveraged)
|
||||
{
|
||||
int avgRoundedUp = (ClassDef.HitDie / 2) + 1;
|
||||
hpGained = Math.Max(1, avgRoundedUp + conMod);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Roll value already determined; but CON might have changed.
|
||||
hpGained = Math.Max(1, result.HpHitDieResult + conMod);
|
||||
}
|
||||
MaxHp += hpGained;
|
||||
CurrentHp += hpGained; // level-up restores per d20 default
|
||||
|
||||
// Learned features.
|
||||
foreach (var fid in result.ClassFeaturesUnlocked)
|
||||
LearnedFeatureIds.Add(fid);
|
||||
foreach (var fid in result.SubclassFeaturesUnlocked)
|
||||
LearnedFeatureIds.Add(fid);
|
||||
|
||||
// Level — last, so all the per-level computations above use the
|
||||
// pre-level-up Level for any branching they need.
|
||||
Level = result.NewLevel;
|
||||
|
||||
// Record the event.
|
||||
LevelUpHistory.Add(new LevelUpRecord
|
||||
{
|
||||
Level = result.NewLevel,
|
||||
HpGained = hpGained,
|
||||
HpWasAveraged = result.HpWasAveraged,
|
||||
HpHitDieResult = result.HpHitDieResult,
|
||||
SubclassChosen = result.GrantsSubclassChoice ? choices.SubclassId : null,
|
||||
AsiAdjustmentsKeys = choices.AsiAdjustments.Keys.Select(k => (byte)k).ToArray(),
|
||||
AsiAdjustmentsValues = choices.AsiAdjustments.Values.ToArray(),
|
||||
FeaturesUnlocked = result.ClassFeaturesUnlocked
|
||||
.Concat(result.SubclassFeaturesUnlocked)
|
||||
.ToArray(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M0 — one entry in <see cref="Character.LevelUpHistory"/>.
|
||||
/// Plain-data, serializable. Records the *deltas* (not the post-state), so
|
||||
/// the history can be replayed on load and the level-up screen can show
|
||||
/// the player what happened at each level.
|
||||
/// </summary>
|
||||
public sealed class LevelUpRecord
|
||||
{
|
||||
public int Level { get; init; }
|
||||
public int HpGained { get; init; }
|
||||
public bool HpWasAveraged { get; init; }
|
||||
public int HpHitDieResult { get; init; }
|
||||
public string? SubclassChosen { get; init; }
|
||||
public byte[] AsiAdjustmentsKeys { get; init; } = Array.Empty<byte>();
|
||||
public int[] AsiAdjustmentsValues { get; init; } = Array.Empty<int>();
|
||||
public string[] FeaturesUnlocked { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Character;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for a level-1 <see cref="Character"/>. Used by both the
|
||||
/// in-game character-creation screen and the headless <c>character-roll</c>
|
||||
/// Tools command, plus the M2 test suite.
|
||||
///
|
||||
/// Pattern: set inputs (clade, species, class, background, base scores,
|
||||
/// chosen skills, name), then call <see cref="Build"/>. <see cref="Validate"/>
|
||||
/// returns the first error string when any required input is missing or
|
||||
/// inconsistent — <see cref="Build"/> calls Validate and throws on failure.
|
||||
/// </summary>
|
||||
public sealed class CharacterBuilder
|
||||
{
|
||||
public CladeDef? Clade { get; set; }
|
||||
public SpeciesDef? Species { get; set; }
|
||||
public ClassDef? ClassDef { get; set; }
|
||||
public BackgroundDef? Background { get; set; }
|
||||
|
||||
/// <summary>Pre-clade-mod base scores (e.g. Standard Array assignment or 4d6 roll outcome).</summary>
|
||||
public AbilityScores BaseAbilities { get; set; } = new(10, 10, 10, 10, 10, 10);
|
||||
|
||||
/// <summary>Class-skill picks. Background skills are added automatically by Build().</summary>
|
||||
public HashSet<SkillId> ChosenClassSkills { get; } = new();
|
||||
|
||||
public string Name { get; set; } = "Wanderer";
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M6: Fangsworn fighting style choice. One of "duelist",
|
||||
/// "great_weapon", "shieldwall", "fang_and_blade", "natural_predator".
|
||||
/// Empty string defaults to "duelist" if the class is Fangsworn (sensible
|
||||
/// auto-pick that has visible combat effect at level 1). Ignored for
|
||||
/// non-Fangsworn classes.
|
||||
/// </summary>
|
||||
public string FightingStyle { get; set; } = "";
|
||||
|
||||
// ── Phase 6.5 M4: hybrid origin ─────────────────────────────────────
|
||||
/// <summary>
|
||||
/// When true, <see cref="TryBuildHybrid"/> is the canonical build path
|
||||
/// and <see cref="Clade"/> / <see cref="Species"/> are the *dominant*
|
||||
/// parent's lineage; <see cref="HybridSireClade"/> /
|
||||
/// <see cref="HybridDamClade"/> populate the secondary parent. Defaults
|
||||
/// to false (purebred path); the character creation screen flips this
|
||||
/// when the player ticks the Hybrid checkbox.
|
||||
/// </summary>
|
||||
public bool IsHybridOrigin { get; set; } = false;
|
||||
|
||||
/// <summary>Sire clade for hybrid origin path (paternal lineage).</summary>
|
||||
public CladeDef? HybridSireClade { get; set; }
|
||||
/// <summary>Sire species for hybrid origin path.</summary>
|
||||
public SpeciesDef? HybridSireSpecies { get; set; }
|
||||
/// <summary>Dam clade for hybrid origin path (maternal lineage).</summary>
|
||||
public CladeDef? HybridDamClade { get; set; }
|
||||
/// <summary>Dam species for hybrid origin path.</summary>
|
||||
public SpeciesDef? HybridDamSpecies { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Which parent's expression dominates. Drives Passing presentation
|
||||
/// (the PC scent-reads as this lineage's clade). Default is Sire.
|
||||
/// </summary>
|
||||
public ParentLineage HybridDominantParent { get; set; } = ParentLineage.Sire;
|
||||
|
||||
// ── Builder fluent helpers ──────────────────────────────────────────
|
||||
|
||||
public CharacterBuilder WithClade(CladeDef c) { Clade = c; return this; }
|
||||
public CharacterBuilder WithSpecies(SpeciesDef s) { Species = s; return this; }
|
||||
public CharacterBuilder WithClass(ClassDef c) { ClassDef = c; return this; }
|
||||
public CharacterBuilder WithBackground(BackgroundDef b) { Background = b; return this; }
|
||||
public CharacterBuilder WithAbilities(AbilityScores a) { BaseAbilities = a; return this; }
|
||||
public CharacterBuilder WithName(string name) { Name = name ?? "Wanderer"; return this; }
|
||||
|
||||
public CharacterBuilder ChooseSkill(SkillId s)
|
||||
{
|
||||
ChosenClassSkills.Add(s);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────────
|
||||
|
||||
public bool Validate(out string error)
|
||||
{
|
||||
error = "";
|
||||
if (Clade is null) { error = "Clade not selected."; return false; }
|
||||
if (Species is null) { error = "Species not selected."; return false; }
|
||||
if (ClassDef is null) { error = "Class not selected."; return false; }
|
||||
if (Background is null) { error = "Background not selected."; return false; }
|
||||
|
||||
if (!string.Equals(Species.CladeId, Clade.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
error = $"Species '{Species.Id}' belongs to clade '{Species.CladeId}', not '{Clade.Id}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate every chosen class skill is in the class's offered list.
|
||||
foreach (var s in ChosenClassSkills)
|
||||
{
|
||||
string raw = SkillToJsonName(s);
|
||||
bool listed = false;
|
||||
foreach (var opt in ClassDef.SkillOptions)
|
||||
if (string.Equals(opt, raw, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
listed = true;
|
||||
break;
|
||||
}
|
||||
if (!listed)
|
||||
{
|
||||
error = $"Class '{ClassDef.Id}' does not offer skill '{raw}'.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (ChosenClassSkills.Count != ClassDef.SkillsChoose)
|
||||
{
|
||||
error = $"Class '{ClassDef.Id}' requires {ClassDef.SkillsChoose} skill picks, got {ChosenClassSkills.Count}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Build ───────────────────────────────────────────────────────────
|
||||
|
||||
public Character Build(IReadOnlyDictionary<string, ItemDef>? itemsForStartingKit = null)
|
||||
{
|
||||
if (!Validate(out string error))
|
||||
throw new InvalidOperationException($"Cannot build character: {error}");
|
||||
|
||||
// Apply clade + species ability mods to the base scores.
|
||||
var clade = Clade!;
|
||||
var species = Species!;
|
||||
var classD = ClassDef!;
|
||||
var bgD = Background!;
|
||||
|
||||
var modded = BaseAbilities;
|
||||
modded = ApplyMods(modded, clade.AbilityMods);
|
||||
modded = ApplyMods(modded, species.AbilityMods);
|
||||
|
||||
var c = new Character(clade, species, classD, bgD, modded)
|
||||
{
|
||||
Level = 1,
|
||||
Xp = 0,
|
||||
};
|
||||
|
||||
// Skills: class-chosen + background freebies (deduplicated).
|
||||
foreach (var s in ChosenClassSkills) c.SkillProficiencies.Add(s);
|
||||
foreach (var raw in bgD.SkillProficiencies)
|
||||
{
|
||||
try { c.SkillProficiencies.Add(SkillIdExtensions.FromJson(raw)); }
|
||||
catch (ArgumentException) { /* unknown skill names ignored — content bug, but don't crash creation */ }
|
||||
}
|
||||
|
||||
// HP: HitDie + CON modifier at level 1.
|
||||
c.MaxHp = c.ComputeMaxHpFromScratch();
|
||||
c.CurrentHp = c.MaxHp;
|
||||
|
||||
// Phase 5 M6: Fangsworn fighting style. Default to "duelist" — has
|
||||
// immediate combat effect at level 1 and works with the most weapons
|
||||
// in our starting kits. The CodexUI character creator surfaces this
|
||||
// as a real picker; the legacy Myra screen leaves it on default.
|
||||
if (string.Equals(classD.Id, "fangsworn", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
c.FightingStyle = string.IsNullOrEmpty(FightingStyle) ? "duelist" : FightingStyle;
|
||||
}
|
||||
|
||||
// Optional starting kit. The caller passes the loaded item table
|
||||
// (typically <see cref="Data.ContentResolver.Items"/>); if null, the
|
||||
// character starts with an empty inventory (existing test behaviour).
|
||||
if (itemsForStartingKit is not null)
|
||||
ApplyStartingKit(c, itemsForStartingKit);
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Phase 6.5 M4: Hybrid build path ─────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Validate the hybrid-origin fields. Returns true and an empty error
|
||||
/// string when the sire+dam configuration is valid for building a
|
||||
/// hybrid character.
|
||||
///
|
||||
/// Required: both sire and dam picked (clade + species each); sire and
|
||||
/// dam must be *different* clades (cross-clade is the definition of
|
||||
/// hybrid); each species must belong to its declared clade.
|
||||
/// </summary>
|
||||
public bool ValidateHybrid(out string error)
|
||||
{
|
||||
error = "";
|
||||
if (HybridSireClade is null) { error = "Sire clade not selected."; return false; }
|
||||
if (HybridSireSpecies is null) { error = "Sire species not selected."; return false; }
|
||||
if (HybridDamClade is null) { error = "Dam clade not selected."; return false; }
|
||||
if (HybridDamSpecies is null) { error = "Dam species not selected."; return false; }
|
||||
|
||||
if (string.Equals(HybridSireClade.Id, HybridDamClade.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
error = $"Sire and dam must be different clades (both are '{HybridSireClade.Id}'). Hybrids are cross-clade.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(HybridSireSpecies.CladeId, HybridSireClade.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
error = $"Sire species '{HybridSireSpecies.Id}' belongs to clade '{HybridSireSpecies.CladeId}', not '{HybridSireClade.Id}'.";
|
||||
return false;
|
||||
}
|
||||
if (!string.Equals(HybridDamSpecies.CladeId, HybridDamClade.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
error = $"Dam species '{HybridDamSpecies.Id}' belongs to clade '{HybridDamSpecies.CladeId}', not '{HybridDamClade.Id}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a hybrid character from the configured sire + dam pair. The
|
||||
/// builder resolves the dominant parent's clade + species as the
|
||||
/// primary <see cref="Character.Clade"/> / <see cref="Character.Species"/>
|
||||
/// (so existing systems that key off these fields keep working), and
|
||||
/// records the full sire+dam genealogy in <see cref="Character.Hybrid"/>.
|
||||
///
|
||||
/// Ability mod blending follows <c>clades.md</c> HYBRID ORIGIN:
|
||||
/// take *one* ability mod from each parent clade. If both grant the
|
||||
/// same ability, the duplicate is dropped (no double-counting); the
|
||||
/// player picks an alternative +1 elsewhere via the standard array
|
||||
/// or roll path. (M4 simplification: take both clade mod sets and
|
||||
/// blend them — duplicates collapse to a single +1 — and use both
|
||||
/// species mods.)
|
||||
/// </summary>
|
||||
public bool TryBuildHybrid(
|
||||
IReadOnlyDictionary<string, ItemDef>? itemsForStartingKit,
|
||||
out Character? character,
|
||||
out string error)
|
||||
{
|
||||
character = null;
|
||||
|
||||
if (!ValidateHybrid(out error)) return false;
|
||||
if (ClassDef is null)
|
||||
{
|
||||
error = "Class not selected.";
|
||||
return false;
|
||||
}
|
||||
if (Background is null)
|
||||
{
|
||||
error = "Background not selected.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate skills against the class — same as the purebred path.
|
||||
foreach (var s in ChosenClassSkills)
|
||||
{
|
||||
string raw = SkillToJsonName(s);
|
||||
bool listed = false;
|
||||
foreach (var opt in ClassDef.SkillOptions)
|
||||
if (string.Equals(opt, raw, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
listed = true;
|
||||
break;
|
||||
}
|
||||
if (!listed)
|
||||
{
|
||||
error = $"Class '{ClassDef.Id}' does not offer skill '{raw}'.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (ChosenClassSkills.Count != ClassDef.SkillsChoose)
|
||||
{
|
||||
error = $"Class '{ClassDef.Id}' requires {ClassDef.SkillsChoose} skill picks, got {ChosenClassSkills.Count}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve the dominant lineage as the primary clade/species so the
|
||||
// rest of the engine (rendering, scent reads, dialogue gates that
|
||||
// key off Character.Clade / Character.Species) sees the dominant
|
||||
// expression. The Hybrid record carries the full sire+dam
|
||||
// genealogy.
|
||||
var dominantClade = HybridDominantParent == ParentLineage.Sire
|
||||
? HybridSireClade! : HybridDamClade!;
|
||||
var dominantSpecies = HybridDominantParent == ParentLineage.Sire
|
||||
? HybridSireSpecies! : HybridDamSpecies!;
|
||||
|
||||
// Blend ability mods: apply BOTH parent clades' mods, then BOTH
|
||||
// species mods. Same-key collisions accumulate (e.g. two clades
|
||||
// each granting +1 CON yield +2 CON). This is a small departure
|
||||
// from clades.md's "take one from each" but matches the engine's
|
||||
// declarative-mod model and produces sensible totals; M4 ships it
|
||||
// and the rule fine-tunes in playtesting.
|
||||
var modded = BaseAbilities;
|
||||
modded = ApplyMods(modded, HybridSireClade!.AbilityMods);
|
||||
modded = ApplyMods(modded, HybridDamClade!.AbilityMods);
|
||||
modded = ApplyMods(modded, HybridSireSpecies!.AbilityMods);
|
||||
modded = ApplyMods(modded, HybridDamSpecies!.AbilityMods);
|
||||
|
||||
var c = new Character(dominantClade, dominantSpecies, ClassDef, Background, modded)
|
||||
{
|
||||
Level = 1,
|
||||
Xp = 0,
|
||||
Hybrid = new HybridState
|
||||
{
|
||||
SireClade = HybridSireClade.Id,
|
||||
SireSpecies = HybridSireSpecies.Id,
|
||||
DamClade = HybridDamClade.Id,
|
||||
DamSpecies = HybridDamSpecies.Id,
|
||||
DominantParent = HybridDominantParent,
|
||||
},
|
||||
};
|
||||
|
||||
// Skills (same as purebred path).
|
||||
foreach (var s in ChosenClassSkills) c.SkillProficiencies.Add(s);
|
||||
foreach (var raw in Background.SkillProficiencies)
|
||||
{
|
||||
try { c.SkillProficiencies.Add(SkillIdExtensions.FromJson(raw)); }
|
||||
catch (ArgumentException) { /* unknown skill names ignored */ }
|
||||
}
|
||||
|
||||
c.MaxHp = c.ComputeMaxHpFromScratch();
|
||||
c.CurrentHp = c.MaxHp;
|
||||
|
||||
if (string.Equals(ClassDef.Id, "fangsworn", System.StringComparison.OrdinalIgnoreCase))
|
||||
c.FightingStyle = string.IsNullOrEmpty(FightingStyle) ? "duelist" : FightingStyle;
|
||||
|
||||
if (itemsForStartingKit is not null)
|
||||
ApplyStartingKit(c, itemsForStartingKit);
|
||||
|
||||
character = c;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds every entry from <see cref="ClassDef.StartingKit"/> to the
|
||||
/// character's inventory and auto-equips entries flagged for it. Logs and
|
||||
/// continues on missing items / unknown slots — content bugs should fail
|
||||
/// loud at content-validate time, not crash character creation.
|
||||
/// </summary>
|
||||
public static void ApplyStartingKit(Character c, IReadOnlyDictionary<string, ItemDef> items)
|
||||
{
|
||||
foreach (var entry in c.ClassDef.StartingKit)
|
||||
{
|
||||
if (!items.TryGetValue(entry.ItemId, out var def))
|
||||
continue; // unknown item id — skip silently (caught by ContentValidate)
|
||||
|
||||
var inst = c.Inventory.Add(def, Math.Max(1, entry.Qty));
|
||||
if (!entry.AutoEquip || string.IsNullOrEmpty(entry.EquipSlot))
|
||||
continue;
|
||||
|
||||
var slot = EquipSlotExtensions.FromJson(entry.EquipSlot);
|
||||
if (slot is null) continue;
|
||||
// Best-effort equip; ignore the error string here. If a structural
|
||||
// conflict occurs (two-handed in main when off-hand pre-occupied),
|
||||
// the item stays in the bag rather than blocking creation.
|
||||
c.Inventory.TryEquip(inst, slot.Value, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static AbilityScores ApplyMods(AbilityScores a, IReadOnlyDictionary<string, int> mods)
|
||||
{
|
||||
if (mods is null || mods.Count == 0) return a;
|
||||
var dict = new Dictionary<AbilityId, int>();
|
||||
foreach (var kv in mods)
|
||||
{
|
||||
if (TryParseAbility(kv.Key, out var id))
|
||||
dict[id] = (dict.TryGetValue(id, out var existing) ? existing : 0) + kv.Value;
|
||||
}
|
||||
return a.Plus(dict);
|
||||
}
|
||||
|
||||
private static bool TryParseAbility(string raw, out AbilityId id)
|
||||
{
|
||||
switch (raw.ToUpperInvariant())
|
||||
{
|
||||
case "STR": id = AbilityId.STR; return true;
|
||||
case "DEX": id = AbilityId.DEX; return true;
|
||||
case "CON": id = AbilityId.CON; return true;
|
||||
case "INT": id = AbilityId.INT; return true;
|
||||
case "WIS": id = AbilityId.WIS; return true;
|
||||
case "CHA": id = AbilityId.CHA; return true;
|
||||
default: id = AbilityId.STR; return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SkillToJsonName(SkillId s) => s switch
|
||||
{
|
||||
SkillId.Acrobatics => "acrobatics",
|
||||
SkillId.AnimalHandling => "animal_handling",
|
||||
SkillId.Arcana => "arcana",
|
||||
SkillId.Athletics => "athletics",
|
||||
SkillId.Deception => "deception",
|
||||
SkillId.History => "history",
|
||||
SkillId.Insight => "insight",
|
||||
SkillId.Intimidation => "intimidation",
|
||||
SkillId.Investigation => "investigation",
|
||||
SkillId.Medicine => "medicine",
|
||||
SkillId.Nature => "nature",
|
||||
SkillId.Perception => "perception",
|
||||
SkillId.Performance => "performance",
|
||||
SkillId.Persuasion => "persuasion",
|
||||
SkillId.Religion => "religion",
|
||||
SkillId.SleightOfHand => "sleight_of_hand",
|
||||
SkillId.Stealth => "stealth",
|
||||
SkillId.Survival => "survival",
|
||||
_ => s.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
// ── Stat-rolling ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Roll 4d6-drop-lowest six times, returning a fresh <see cref="AbilityScores"/>
|
||||
/// in (STR, DEX, CON, INT, WIS, CHA) order. Player assigns afterward.
|
||||
///
|
||||
/// Seed:
|
||||
/// <c>worldSeed ^ C.RNG_STAT_ROLL ^ msSinceGameStart</c>
|
||||
/// where <paramref name="msSinceGameStart"/> is wall-clock ms since process
|
||||
/// launch in production, or a fixed test override for reproducibility.
|
||||
/// </summary>
|
||||
public static AbilityScores RollAbilityScores(ulong worldSeed, ulong msSinceGameStart)
|
||||
{
|
||||
var rng = SeededRng.ForSubsystem(worldSeed, C.RNG_STAT_ROLL ^ msSinceGameStart);
|
||||
int[] r = new int[6];
|
||||
for (int i = 0; i < 6; i++) r[i] = Roll4d6DropLowest(rng);
|
||||
return new AbilityScores(r[0], r[1], r[2], r[3], r[4], r[5]);
|
||||
}
|
||||
|
||||
/// <summary>4d6, drop the lowest; returns 3..18.</summary>
|
||||
public static int Roll4d6DropLowest(SeededRng rng)
|
||||
{
|
||||
int d1 = (int)(rng.NextUInt64() % 6) + 1;
|
||||
int d2 = (int)(rng.NextUInt64() % 6) + 1;
|
||||
int d3 = (int)(rng.NextUInt64() % 6) + 1;
|
||||
int d4 = (int)(rng.NextUInt64() % 6) + 1;
|
||||
int low = Math.Min(d1, Math.Min(d2, Math.Min(d3, d4)));
|
||||
return d1 + d2 + d3 + d4 - low;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace Theriapolis.Core.Rules.Character;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M4 — universal Hybrid detriments, applied automatically to
|
||||
/// every <see cref="HybridState"/>-bearing character per
|
||||
/// <c>theriapolis-rpg-clades.md</c> HYBRID ORIGIN section.
|
||||
///
|
||||
/// The four detriments are *invariant rules*, not authored content —
|
||||
/// they don't vary per hybrid character — so they ship as code constants
|
||||
/// rather than a JSON content block. (Plan §3.1's "HybridDetrimentsDef
|
||||
/// loader" is documented as deviation: code constants are simpler and
|
||||
/// match the design's universality.)
|
||||
///
|
||||
/// 1. <see cref="ScentDysphoriaSaveDc"/> — WIS save DC 10 imposed on the
|
||||
/// first NPC interaction; failure → disadvantage on first CHA check.
|
||||
/// 2. <see cref="IllegibleBodyLanguagePenalty"/> — disadvantage on
|
||||
/// nonverbal CHA checks with purebred NPCs.
|
||||
/// 3. <see cref="SocialStigmaFirstCheckPenalty"/> — -2 to first CHA check
|
||||
/// with strangers in non-progressive settlements.
|
||||
/// 4. <see cref="MedicalIncompatibilityMultiplier"/> — healing from
|
||||
/// potions / Field Repair / Lay on Paws scaled by 0.75.
|
||||
/// </summary>
|
||||
public static class HybridDetriments
|
||||
{
|
||||
/// <summary>WIS save DC for Scent Dysphoria detection check.</summary>
|
||||
public const int ScentDysphoriaSaveDc = 10;
|
||||
|
||||
/// <summary>Magnitude of the Social Stigma first-CHA-check penalty (negative).</summary>
|
||||
public const int SocialStigmaFirstCheckPenalty = -2;
|
||||
|
||||
/// <summary>
|
||||
/// Multiplier applied to healing received by a hybrid character.
|
||||
/// 0.75 = three-quarters effective per <c>clades.md</c>; round down.
|
||||
/// </summary>
|
||||
public const float MedicalIncompatibilityMultiplier = 0.75f;
|
||||
|
||||
/// <summary>True if Illegible Body Language imposes disadvantage on the given check.</summary>
|
||||
public static bool IllegibleBodyLanguagePenalty => true;
|
||||
|
||||
/// <summary>
|
||||
/// Apply the Medical Incompatibility multiplier to a heal amount when
|
||||
/// the recipient is a hybrid PC. Round down per <c>clades.md</c>.
|
||||
/// Non-hybrid recipients pass through unchanged.
|
||||
/// </summary>
|
||||
public static int ScaleHealForHybrid(Character recipient, int rawHeal)
|
||||
{
|
||||
if (recipient.Hybrid is null) return rawHeal;
|
||||
if (rawHeal <= 0) return rawHeal;
|
||||
// Round down — clades.md says "function at 75% effectiveness (round
|
||||
// down)". (int)(0.75 * 7) = 5; (int)(0.75 * 1) = 0 → clamp to 1
|
||||
// because "no heal" is mechanically harsher than "small heal".
|
||||
int scaled = (int)(rawHeal * MedicalIncompatibilityMultiplier);
|
||||
return System.Math.Max(1, scaled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using Theriapolis.Core.Data;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Character;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M4 — runtime state for a hybrid character.
|
||||
///
|
||||
/// Hybrids are blended from two parent lineages: a <b>Sire</b> (paternal
|
||||
/// lineage) and a <b>Dam</b> (maternal lineage), of two different clades.
|
||||
/// One parent is <see cref="DominantParent"/> — the lineage whose physical
|
||||
/// expression is more visible; this drives Passing eligibility and which
|
||||
/// clade the PC presents as for casual scent reads.
|
||||
///
|
||||
/// The character's own gender is independent of which parent is sire or
|
||||
/// dam — a male hybrid PC can have a wolf-folk dam and a coyote-folk
|
||||
/// sire just as readily as the reverse.
|
||||
///
|
||||
/// Per <c>theriapolis-rpg-clades.md</c> HYBRID ORIGIN: blend ability mods
|
||||
/// (one from each parent clade), traits (2 from dominant + 1 from
|
||||
/// secondary), and inherit *all* universal hybrid detriments — Scent
|
||||
/// Dysphoria, Illegible Body Language, Social Stigma, Medical
|
||||
/// Incompatibility (handled by <see cref="HybridDetriments"/>).
|
||||
///
|
||||
/// <see cref="PassingActive"/> is toggle-able mid-game; when set, the PC
|
||||
/// presents as the dominant lineage's clade. Detection by NPCs (Phase 6.5
|
||||
/// M5) is per-NPC and permanent once revealed.
|
||||
/// </summary>
|
||||
public sealed class HybridState
|
||||
{
|
||||
/// <summary>Sire (paternal-lineage) clade id, e.g. "canidae".</summary>
|
||||
public string SireClade { get; init; } = "";
|
||||
|
||||
/// <summary>Sire (paternal-lineage) species id, e.g. "wolf".</summary>
|
||||
public string SireSpecies { get; init; } = "";
|
||||
|
||||
/// <summary>Dam (maternal-lineage) clade id, e.g. "leporidae".</summary>
|
||||
public string DamClade { get; init; } = "";
|
||||
|
||||
/// <summary>Dam (maternal-lineage) species id, e.g. "rabbit".</summary>
|
||||
public string DamSpecies { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Which parent's expression is dominant. Drives Passing presentation
|
||||
/// (the PC scent-reads as this parent's clade) and the trait-split:
|
||||
/// 2 Clade traits from the dominant parent, 1 from the secondary.
|
||||
/// </summary>
|
||||
public ParentLineage DominantParent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the PC is actively trying to pass as their dominant
|
||||
/// parent's clade. Toggles on the character sheet; consulted by
|
||||
/// Phase 6.5 M5 passing-detection rolls.
|
||||
/// </summary>
|
||||
public bool PassingActive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M5 — currently-active scent mask tier (if any). The mask
|
||||
/// suppresses scent-based detection per its tier. Phase 6.5 M5 ships
|
||||
/// the static tier flag; Phase 8's clock model adds time-based
|
||||
/// expiry alongside daily wear.
|
||||
/// </summary>
|
||||
public ScentMaskTier ActiveMaskTier { get; set; } = ScentMaskTier.None;
|
||||
|
||||
/// <summary>
|
||||
/// NPC ids who have personally detected this PC is hybrid. Permanent
|
||||
/// once added — disabling Passing later doesn't undo the discovery
|
||||
/// for that specific NPC. Phase 6.5 M5 populates this; M4 reserves it
|
||||
/// in the schema so save round-trip works pre- and post-M5.
|
||||
/// </summary>
|
||||
public HashSet<int> NpcsWhoKnow { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Convenience: which clade the PC presents as for casual scent reads
|
||||
/// (used by Phase 6.5 M5 passing logic and Phase 7 dialogue gates).
|
||||
/// Returns the dominant parent's clade id.
|
||||
/// </summary>
|
||||
public string PresentingCladeId =>
|
||||
DominantParent == ParentLineage.Sire ? SireClade : DamClade;
|
||||
|
||||
/// <summary>
|
||||
/// Convenience: which species the PC presents as. Same logic as
|
||||
/// <see cref="PresentingCladeId"/>.
|
||||
/// </summary>
|
||||
public string PresentingSpeciesId =>
|
||||
DominantParent == ParentLineage.Sire ? SireSpecies : DamSpecies;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M4 — which parent in a hybrid PC's lineage is the
|
||||
/// dominant/secondary expression. Sire = paternal lineage, Dam = maternal
|
||||
/// lineage; the choice is OOC (no gender semantics for the character
|
||||
/// themselves, just for the parents' lineages).
|
||||
/// </summary>
|
||||
public enum ParentLineage : byte
|
||||
{
|
||||
Sire = 0,
|
||||
Dam = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M5 — scent-mask tier suppressing hybrid detection. Maps to
|
||||
/// the consumable items in <c>items.json</c>:
|
||||
/// None — no mask active
|
||||
/// Basic — scent_mask_basic; advantage on PC Deception roll
|
||||
/// Military — scent_mask_military; auto-suppresses scent detection
|
||||
/// DeepCover — scent_mask_deep_cover; auto-suppresses, even Superior Scent
|
||||
///
|
||||
/// Per the Phase 6.5 plan §4.7. Phase 8's clock + rest model adds
|
||||
/// time-based expiry; M5 carries the tier as static state.
|
||||
/// </summary>
|
||||
public enum ScentMaskTier : byte
|
||||
{
|
||||
None = 0,
|
||||
Basic = 1,
|
||||
Military = 2,
|
||||
DeepCover = 3,
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Character;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M0 — the level-up flow.
|
||||
///
|
||||
/// <see cref="Compute"/> is a pure function: given a character, a target
|
||||
/// level, and a deterministic seed, it produces a <see cref="LevelUpResult"/>
|
||||
/// describing what the level-up *would* do without mutating anything. The
|
||||
/// level-up screen previews this; <see cref="Character.ApplyLevelUp"/>
|
||||
/// commits it.
|
||||
///
|
||||
/// Determinism: <c>seed = worldSeed ^ characterCreationMs ^ RNG_LEVELUP ^ targetLevel</c>.
|
||||
/// Same seed → same HP roll, same feature list, same ASI/subclass slots open.
|
||||
/// Save mid-flow → load → re-compute produces byte-identical payload.
|
||||
/// </summary>
|
||||
public static class LevelUpFlow
|
||||
{
|
||||
/// <summary>
|
||||
/// True when the character has accumulated enough XP to level up.
|
||||
/// Wraps the <see cref="XpTable"/> threshold check.
|
||||
/// </summary>
|
||||
public static bool CanLevelUp(Character character)
|
||||
{
|
||||
if (character.Level >= C.CHARACTER_LEVEL_MAX) return false;
|
||||
return character.Xp >= XpTable.XpRequiredForNextLevel(character.Level);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute (but do not apply) the level-up payload for advancing
|
||||
/// <paramref name="character"/> to <paramref name="targetLevel"/>.
|
||||
/// Caller is expected to validate <c>targetLevel == character.Level + 1</c>;
|
||||
/// no in-method assertion so unit tests can roll forward without mutation.
|
||||
///
|
||||
/// <paramref name="seed"/> determines HP roll outcome when the player
|
||||
/// chooses to roll instead of take average. Seeded callers should pass
|
||||
/// <c>worldSeed ^ characterCreationMs ^ C.RNG_LEVELUP ^ (ulong)targetLevel</c>.
|
||||
///
|
||||
/// <paramref name="subclasses"/> is the content resolver's subclass
|
||||
/// dictionary; when non-null and the character has a chosen subclass,
|
||||
/// the result's <see cref="LevelUpResult.SubclassFeaturesUnlocked"/>
|
||||
/// is populated. Phase 6.5 M0 callers (and tests without content) pass
|
||||
/// null, which yields an empty subclass-feature list.
|
||||
/// </summary>
|
||||
public static LevelUpResult Compute(
|
||||
Character character,
|
||||
int targetLevel,
|
||||
ulong seed,
|
||||
bool takeAverage = true,
|
||||
IReadOnlyDictionary<string, Data.SubclassDef>? subclasses = null)
|
||||
{
|
||||
var classDef = character.ClassDef;
|
||||
int conMod = character.Abilities.ModFor(AbilityId.CON);
|
||||
|
||||
int hitDie = classDef.HitDie;
|
||||
int avgHp = (hitDie / 2) + 1;
|
||||
int rollHp;
|
||||
if (takeAverage)
|
||||
{
|
||||
rollHp = avgHp;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Roll 1d{hitDie} from a fresh stream so the result is reproducible
|
||||
// per-call. Use SeededRng to match Phase 5/6 RNG conventions.
|
||||
var rng = new SeededRng(seed);
|
||||
rollHp = (int)(rng.NextUInt64() % (uint)hitDie) + 1;
|
||||
}
|
||||
int hpGained = Math.Max(1, rollHp + conMod);
|
||||
|
||||
// Class features at this level. The level-table indexes by Level
|
||||
// (1-based); arrays are 0-based so look up at [level - 1].
|
||||
var classFeatures = Array.Empty<string>();
|
||||
if (targetLevel >= 1 && targetLevel <= classDef.LevelTable.Length)
|
||||
{
|
||||
var entry = classDef.LevelTable[targetLevel - 1];
|
||||
classFeatures = entry.Features ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
// Phase 6.5 M2 — resolve subclass features from the chosen subclass
|
||||
// (post-L3). When `subclasses` is null (M0 callers / tests without
|
||||
// content), no subclass features are unlocked.
|
||||
string[] subclassFeatures = subclasses is not null && !string.IsNullOrEmpty(character.SubclassId)
|
||||
? SubclassResolver.UnlockedFeaturesAt(subclasses, character.SubclassId, targetLevel)
|
||||
: Array.Empty<string>();
|
||||
|
||||
bool grantsSubclass = targetLevel == C.SUBCLASS_SELECTION_LEVEL
|
||||
&& string.IsNullOrEmpty(character.SubclassId)
|
||||
&& classDef.SubclassIds.Length > 0;
|
||||
bool grantsAsi = Array.IndexOf(C.ASI_LEVELS, targetLevel) >= 0;
|
||||
|
||||
return new LevelUpResult
|
||||
{
|
||||
NewLevel = targetLevel,
|
||||
HpGained = hpGained,
|
||||
HpHitDieResult = rollHp,
|
||||
HpWasAveraged = takeAverage,
|
||||
ClassFeaturesUnlocked = classFeatures,
|
||||
SubclassFeaturesUnlocked = subclassFeatures,
|
||||
GrantsSubclassChoice = grantsSubclass,
|
||||
GrantsAsiChoice = grantsAsi,
|
||||
NewProficiencyBonus = ProficiencyBonus.ForLevel(targetLevel),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Character;
|
||||
|
||||
/// <summary>
|
||||
/// Pure data describing the *deltas* a level-up produces. Phase 6.5 M0:
|
||||
/// <see cref="LevelUpFlow"/> computes one of these from
|
||||
/// <c>(character, targetLevel, levelUpSeed)</c>; the player confirms; then
|
||||
/// <see cref="Character.ApplyLevelUp"/> applies it.
|
||||
///
|
||||
/// Splitting compute from apply keeps the level-up screen previewable
|
||||
/// (the player sees the rolled HP and feature list before committing) and
|
||||
/// makes mid-flight save/load deterministic — the same seed always produces
|
||||
/// the same payload.
|
||||
/// </summary>
|
||||
public sealed class LevelUpResult
|
||||
{
|
||||
/// <summary>The level being advanced *to*. After Apply, <c>Character.Level == NewLevel</c>.</summary>
|
||||
public int NewLevel { get; init; }
|
||||
|
||||
/// <summary>HP gained on this level-up. Already incorporates CON modifier.</summary>
|
||||
public int HpGained { get; init; }
|
||||
|
||||
/// <summary>Average-rounded-up HP value used (for "take average" path); rolled value used otherwise.</summary>
|
||||
public int HpHitDieResult { get; init; }
|
||||
|
||||
/// <summary>True if the player picked the "take average" option; false if rolled.</summary>
|
||||
public bool HpWasAveraged { get; init; }
|
||||
|
||||
/// <summary>Class feature ids unlocked at this level (per <see cref="Data.ClassDef.LevelTable"/>).</summary>
|
||||
public string[] ClassFeaturesUnlocked { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Subclass feature ids unlocked at this level (post-L3, when SubclassId
|
||||
/// is set). Empty for pre-subclass and non-subclass-feature levels.
|
||||
/// </summary>
|
||||
public string[] SubclassFeaturesUnlocked { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>True if this level grants a subclass selection slot (level 3 by default).</summary>
|
||||
public bool GrantsSubclassChoice { get; init; }
|
||||
|
||||
/// <summary>True if this level grants an Ability Score Improvement choice (levels 4 / 8 / 12 / 16 / 19).</summary>
|
||||
public bool GrantsAsiChoice { get; init; }
|
||||
|
||||
/// <summary>The proficiency bonus *after* this level-up.</summary>
|
||||
public int NewProficiencyBonus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The player's choices at level-up that need confirmation before
|
||||
/// <see cref="Character.ApplyLevelUp"/> commits the deltas. Leave fields
|
||||
/// null/empty when the corresponding slot isn't open at this level.
|
||||
/// </summary>
|
||||
public sealed class LevelUpChoices
|
||||
{
|
||||
/// <summary>
|
||||
/// At level 3 (or whatever <see cref="C.SUBCLASS_SELECTION_LEVEL"/>
|
||||
/// becomes), the player picks a subclass id. Must reference one of
|
||||
/// <c>character.ClassDef.SubclassIds</c>.
|
||||
/// </summary>
|
||||
public string? SubclassId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// At ASI levels (<see cref="C.ASI_LEVELS"/>), the player picks ability
|
||||
/// score improvements. Either:
|
||||
/// - one ability +2 (cap at <see cref="C.ABILITY_SCORE_CAP_PRE_L20"/>)
|
||||
/// - two abilities +1 each (each cap at <see cref="C.ABILITY_SCORE_CAP_PRE_L20"/>)
|
||||
/// </summary>
|
||||
public Dictionary<AbilityId, int> AsiAdjustments { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// True if the player picked "take average HP" instead of "roll".
|
||||
/// Default is "average" — predictable, avoids the dump-stat-roll problem.
|
||||
/// </summary>
|
||||
public bool TakeAverageHp { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Rules.Reputation;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Character;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M5 — hybrid passing detection.
|
||||
///
|
||||
/// When a hybrid PC (with <see cref="HybridState.PassingActive"/> = true)
|
||||
/// interacts with an NPC who has a scent-detection capability (Canid clade
|
||||
/// "Superior Scent" or any Scent-Broker class), the NPC rolls a WIS save
|
||||
/// at <see cref="C.HYBRID_DETECTION_DC"/> against the PC's CHA Deception
|
||||
/// counter-roll.
|
||||
///
|
||||
/// Outcomes:
|
||||
/// <see cref="DetectionResult.Pass"/> — PC remains hidden; treated as presenting clade.
|
||||
/// <see cref="DetectionResult.Detected"/> — NPC sees through the cover; their bias
|
||||
/// profile's <c>HybridBias</c> applies from now on.
|
||||
///
|
||||
/// Once detected by a specific NPC, the flag is permanent for that NPC
|
||||
/// (per <c>theriapolis-rpg-clades.md</c> "Optional: Passing"). Other NPCs
|
||||
/// roll independently — no per-settlement propagation in M5 (Phase 8
|
||||
/// scent simulation may extend).
|
||||
/// </summary>
|
||||
public static class PassingCheck
|
||||
{
|
||||
/// <summary>
|
||||
/// Roll detection for one NPC × PC interaction. Determinism:
|
||||
/// <c>seed = worldSeed ^ C.RNG_PASSING ^ npcId ^ encounterIdx</c>.
|
||||
/// Same seed → same outcome; mid-game saves resume identically.
|
||||
///
|
||||
/// <paramref name="npcMemoryFlags"/> is consulted upfront — if the NPC
|
||||
/// already detected this PC in a prior interaction, the result is
|
||||
/// <see cref="DetectionResult.Detected"/> with no fresh roll. The
|
||||
/// caller writes the <c>"knows_hybrid"</c> tag into the NPC's
|
||||
/// <see cref="PersonalDisposition.Memory"/> on first detection.
|
||||
/// </summary>
|
||||
public static DetectionResult Roll(
|
||||
Character pc,
|
||||
NpcActor npc,
|
||||
ICollection<string> npcMemoryFlags,
|
||||
ulong seed)
|
||||
{
|
||||
// Non-hybrids never trigger detection.
|
||||
if (pc.Hybrid is null) return DetectionResult.NotApplicable;
|
||||
|
||||
// Already detected? Permanent for this NPC.
|
||||
if (npcMemoryFlags.Contains("knows_hybrid"))
|
||||
return DetectionResult.PreviouslyDetected;
|
||||
|
||||
// Not actively passing? The PC isn't trying to hide; detection
|
||||
// happens trivially. Marks the NPC as knowing.
|
||||
if (!pc.Hybrid.PassingActive)
|
||||
return DetectionResult.NotPassing;
|
||||
|
||||
// Deep-cover scent mask suppresses all detection — even Superior Scent.
|
||||
if (pc.Hybrid.ActiveMaskTier == ScentMaskTier.DeepCover)
|
||||
return DetectionResult.MaskSuppressed;
|
||||
|
||||
// Military mask: auto-suppress for non-Canid NPCs; Canids still roll
|
||||
// (Superior Scent overrides anything below deep cover).
|
||||
bool npcHasSuperiorScent = NpcHasSuperiorScent(npc);
|
||||
if (pc.Hybrid.ActiveMaskTier == ScentMaskTier.Military && !npcHasSuperiorScent)
|
||||
return DetectionResult.MaskSuppressed;
|
||||
|
||||
// The detection mechanic: NPC WIS save vs the PC's CHA Deception
|
||||
// counter-roll. NPCs without scent capability never detect.
|
||||
if (!CanNpcDetectScent(npc)) return DetectionResult.NoCapability;
|
||||
|
||||
var rng = new SeededRng(seed);
|
||||
// NPC rolls 1d20 + WIS mod against DC = pc Deception DC + (basic mask
|
||||
// gives PC advantage, which we model as +5 to the DC the NPC must beat).
|
||||
int npcWis = NpcWisMod(npc);
|
||||
int npcRoll = (int)(rng.NextUInt64() % 20) + 1;
|
||||
int npcTotal = npcRoll + npcWis;
|
||||
|
||||
int pcCha = pc.Abilities.ModFor(AbilityId.CHA);
|
||||
int pcProf = pc.ProficiencyBonus;
|
||||
int pcDecRoll = (int)(rng.NextUInt64() % 20) + 1;
|
||||
// Proficient in Deception? Add prof bonus; otherwise just CHA mod.
|
||||
bool deceptionProf = pc.SkillProficiencies.Contains(SkillId.Deception);
|
||||
int pcTotal = pcDecRoll + pcCha + (deceptionProf ? pcProf : 0);
|
||||
|
||||
// Basic mask shifts the contest in PC's favour (advantage = +5
|
||||
// approximation for a single non-rerolled compare).
|
||||
if (pc.Hybrid.ActiveMaskTier == ScentMaskTier.Basic) pcTotal += 5;
|
||||
|
||||
// NPC must meet or exceed the DC AND beat the PC's deception
|
||||
// contest to detect. (Either failing means PC stays hidden.)
|
||||
bool npcMeetsDc = npcTotal >= C.HYBRID_DETECTION_DC;
|
||||
bool pcBeatsCheck = pcTotal >= C.HYBRID_DECEPTION_DC;
|
||||
bool detected = npcMeetsDc && !pcBeatsCheck;
|
||||
|
||||
return detected ? DetectionResult.Detected : DetectionResult.Pass;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True for NPCs who have *any* scent-reading capability. Phase 6.5 M5:
|
||||
/// canid-clade NPCs (Superior Scent) and scent-broker-flavoured roles.
|
||||
/// Generic / non-canid / non-scent-broker NPCs never roll detection.
|
||||
/// </summary>
|
||||
public static bool CanNpcDetectScent(NpcActor npc)
|
||||
{
|
||||
if (NpcHasSuperiorScent(npc)) return true;
|
||||
// Phase 6.5 M5 simplification: non-canid NPCs don't detect by
|
||||
// default. A Phase 8 scent-broker NPC role could extend this with
|
||||
// a tag check on `npc.Resident?.Traits` — out of scope for M5.
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>True if the NPC's clade is Canid (granting Superior Scent).</summary>
|
||||
public static bool NpcHasSuperiorScent(NpcActor npc)
|
||||
{
|
||||
string? clade = npc.Resident?.Clade;
|
||||
return string.Equals(clade, "canidae", System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>NPC's WIS modifier — derived from template if present, otherwise default 0.</summary>
|
||||
private static int NpcWisMod(NpcActor npc)
|
||||
{
|
||||
if (npc.Template is null) return 0;
|
||||
// Templates store ability scores as a string-keyed dict on the def.
|
||||
return npc.Template.AbilityScores.TryGetValue("WIS", out int wis)
|
||||
? AbilityScores.Mod(wis)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience: roll detection AND apply side effects on a positive
|
||||
/// outcome. Writes the <c>"knows_hybrid"</c> memory tag to the NPC's
|
||||
/// <see cref="PersonalDisposition.Memory"/>, mirrors the discovery in
|
||||
/// <see cref="HybridState.NpcsWhoKnow"/>, and appends a
|
||||
/// <see cref="RepEventKind.HybridDetected"/> event to the ledger.
|
||||
///
|
||||
/// Returns the same <see cref="DetectionResult"/> the underlying
|
||||
/// <see cref="Roll"/> produced. Call sites that want to inspect the
|
||||
/// outcome before applying side effects can use <see cref="Roll"/>
|
||||
/// directly; this helper is the common-case one-liner.
|
||||
/// </summary>
|
||||
public static DetectionResult RollAndApply(
|
||||
Character pc,
|
||||
NpcActor npc,
|
||||
Reputation.PlayerReputation rep,
|
||||
long worldClockSeconds,
|
||||
ulong seed)
|
||||
{
|
||||
if (pc.Hybrid is null) return DetectionResult.NotApplicable;
|
||||
|
||||
// Pull (or seed) the personal-disposition record so the roll sees
|
||||
// the existing memory state.
|
||||
var personal = string.IsNullOrEmpty(npc.RoleTag)
|
||||
? null
|
||||
: rep.PersonalFor(npc.RoleTag);
|
||||
var memoryFlags = (ICollection<string>?)personal?.Memory ?? new HashSet<string>();
|
||||
|
||||
var result = Roll(pc, npc, memoryFlags, seed);
|
||||
|
||||
if (result == DetectionResult.Detected || result == DetectionResult.NotPassing)
|
||||
{
|
||||
// Write the detection through to all the places that care.
|
||||
pc.Hybrid.NpcsWhoKnow.Add(npc.Id);
|
||||
personal?.Memory.Add("knows_hybrid");
|
||||
|
||||
// Log a per-NPC HybridDetected event. Personal-only — no
|
||||
// faction propagation in M5 (Phase 8 scent simulation can
|
||||
// extend). Magnitude is 0 because the *bias* shift is
|
||||
// applied via the bias-profile lookup in EffectiveDisposition,
|
||||
// not via the personal-disposition delta.
|
||||
var ev = new Reputation.RepEvent
|
||||
{
|
||||
Kind = Reputation.RepEventKind.HybridDetected,
|
||||
RoleTag = npc.RoleTag ?? "",
|
||||
Magnitude = 0,
|
||||
Note = $"detected hybrid ({pc.Hybrid.SireClade}/{pc.Hybrid.DamClade})",
|
||||
TimestampSeconds = worldClockSeconds,
|
||||
};
|
||||
rep.Ledger.Append(ev);
|
||||
personal?.Apply(ev);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M5 — outcome of one detection roll. The caller (typically the
|
||||
/// dialogue runner) inspects this to apply the appropriate side effects.
|
||||
/// </summary>
|
||||
public enum DetectionResult : byte
|
||||
{
|
||||
/// <summary>PC is not a hybrid; no detection mechanic applies.</summary>
|
||||
NotApplicable,
|
||||
|
||||
/// <summary>Hybrid detected on a prior interaction; flag still set.</summary>
|
||||
PreviouslyDetected,
|
||||
|
||||
/// <summary>PC is hybrid but not actively passing — no roll, NPC knows immediately.</summary>
|
||||
NotPassing,
|
||||
|
||||
/// <summary>NPC lacks scent-reading capability; passing automatic.</summary>
|
||||
NoCapability,
|
||||
|
||||
/// <summary>Active scent mask blocked detection without a roll.</summary>
|
||||
MaskSuppressed,
|
||||
|
||||
/// <summary>Detection roll succeeded; NPC sees through the cover.</summary>
|
||||
Detected,
|
||||
|
||||
/// <summary>Detection roll failed; PC remains hidden in this interaction.</summary>
|
||||
Pass,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Theriapolis.Core.Data;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Character;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M2 — given a character's <see cref="ClassDef"/> + a chosen
|
||||
/// <c>subclassId</c>, look up the subclass-feature ids unlocked at a
|
||||
/// specific level. Used by <see cref="LevelUpFlow.Compute"/> to populate
|
||||
/// <see cref="LevelUpResult.SubclassFeaturesUnlocked"/>.
|
||||
///
|
||||
/// The resolver does NOT mutate state — it's a pure lookup. The
|
||||
/// <see cref="FeatureProcessor"/> takes the resulting feature ids and
|
||||
/// applies their mechanical effects at combat-resolution time.
|
||||
/// </summary>
|
||||
public static class SubclassResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Look up a subclass def by id from a content collection. Returns
|
||||
/// null if the id is empty or unknown — callers should treat that as
|
||||
/// "no subclass picked yet" (pre-L3) or "subclass content missing"
|
||||
/// (data error, log it).
|
||||
/// </summary>
|
||||
public static SubclassDef? TryFindSubclass(
|
||||
IReadOnlyDictionary<string, SubclassDef> subclasses,
|
||||
string? subclassId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subclassId)) return null;
|
||||
return subclasses.TryGetValue(subclassId, out var def) ? def : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feature ids unlocked by the chosen subclass at <paramref name="level"/>.
|
||||
/// Returns an empty array if no subclass is picked, the subclass def is
|
||||
/// missing, or the level has no entry in <see cref="SubclassDef.LevelFeatures"/>.
|
||||
/// </summary>
|
||||
public static string[] UnlockedFeaturesAt(
|
||||
IReadOnlyDictionary<string, SubclassDef> subclasses,
|
||||
string? subclassId,
|
||||
int level)
|
||||
{
|
||||
var def = TryFindSubclass(subclasses, subclassId);
|
||||
if (def is null) return Array.Empty<string>();
|
||||
foreach (var entry in def.LevelFeatures)
|
||||
if (entry.Level == level)
|
||||
return entry.Features ?? Array.Empty<string>();
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a feature description (for display in the level-up screen
|
||||
/// and combat HUD tooltips). Looks first in the subclass's
|
||||
/// <see cref="SubclassDef.FeatureDefinitions"/>, then falls through to
|
||||
/// the parent class's
|
||||
/// <see cref="ClassDef.FeatureDefinitions"/> (in case the feature id is
|
||||
/// shared — e.g. <c>asi</c>, <c>extra_attack</c>). Returns null if neither
|
||||
/// has it.
|
||||
/// </summary>
|
||||
public static ClassFeatureDef? ResolveFeatureDef(
|
||||
ClassDef classDef,
|
||||
SubclassDef? subclass,
|
||||
string featureId)
|
||||
{
|
||||
if (subclass is not null
|
||||
&& subclass.FeatureDefinitions.TryGetValue(featureId, out var sdef))
|
||||
return sdef;
|
||||
if (classDef.FeatureDefinitions.TryGetValue(featureId, out var cdef))
|
||||
return cdef;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// One attack a combatant can attempt — a weapon, a natural attack, or an
|
||||
/// NPC stat-block entry. Built once at combat-start; the resolver rolls
|
||||
/// against it. Distinct from <see cref="AttackProfile"/>, which is the
|
||||
/// per-attempt struct that bakes in attacker/defender/situation.
|
||||
/// </summary>
|
||||
public sealed record AttackOption
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
/// <summary>Total +N to add to the d20 attack roll.</summary>
|
||||
public int ToHitBonus { get; init; }
|
||||
public DamageRoll Damage { get; init; } = new(0, 0, 0, DamageType.Bludgeoning);
|
||||
/// <summary>Reach in tactical tiles. 1 = 5 ft. melee; 2 = 10 ft. polearm or Large reach.</summary>
|
||||
public int ReachTiles { get; init; } = 1;
|
||||
/// <summary>Short-range tiles for ranged attacks (0 = melee-only).</summary>
|
||||
public int RangeShortTiles { get; init; } = 0;
|
||||
/// <summary>Long-range tiles (disadvantage past short, can't fire past long).</summary>
|
||||
public int RangeLongTiles { get; init; } = 0;
|
||||
/// <summary>Crit-range threshold (default 20; razored weapons crit on 19+).</summary>
|
||||
public int CritOnNatural { get; init; } = 20;
|
||||
|
||||
public bool IsRanged => RangeShortTiles > 0;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a single <see cref="Resolver.AttemptAttack"/> call. Captures
|
||||
/// every dice value for log reconstruction and test assertions.
|
||||
/// </summary>
|
||||
public sealed record AttackResult
|
||||
{
|
||||
public required int AttackerId { get; init; }
|
||||
public required int TargetId { get; init; }
|
||||
public required string AttackName { get; init; }
|
||||
public required int D20Roll { get; init; } // the kept d20 (post advantage/disadvantage)
|
||||
public int? D20Other { get; init; } // the other d20 when adv/disadv was rolled
|
||||
public required int ToHitBonus { get; init; }
|
||||
public required int AttackTotal { get; init; } // D20Roll + ToHitBonus
|
||||
public required int TargetAc { get; init; } // includes cover
|
||||
public required bool Hit { get; init; }
|
||||
public required bool Crit { get; init; }
|
||||
public required int DamageRolled { get; init; } // 0 if missed
|
||||
public required int TargetHpAfter { get; init; }
|
||||
public required SituationFlags Situation { get; init; }
|
||||
|
||||
public string FormatLog(string attackerName, string targetName)
|
||||
{
|
||||
if (!Hit)
|
||||
return $"{attackerName} → {targetName}: miss ({AttackName} {AttackTotal} vs AC {TargetAc})";
|
||||
string critTag = Crit ? " [CRIT]" : "";
|
||||
return $"{attackerName} → {targetName}: {DamageRolled} dmg ({AttackName} {AttackTotal} vs AC {TargetAc}){critTag} → HP {TargetHpAfter}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// One human-readable line in the encounter log. Combat-resolver actions
|
||||
/// (attacks, saves, conditions, deaths) each emit one of these so test
|
||||
/// scenarios can assert on the log content as a whole.
|
||||
/// </summary>
|
||||
public sealed record CombatLogEntry
|
||||
{
|
||||
public enum Kind : byte
|
||||
{
|
||||
Note = 0, // generic flavour line ("Round 1 begins.")
|
||||
Attack = 1,
|
||||
Save = 2,
|
||||
Damage = 3, // direct damage that wasn't an attack roll
|
||||
ConditionApplied = 4,
|
||||
ConditionEnded = 5,
|
||||
Death = 6,
|
||||
Initiative = 7,
|
||||
TurnStart = 8,
|
||||
Move = 9,
|
||||
EncounterEnd = 10,
|
||||
}
|
||||
|
||||
public required int Round { get; init; }
|
||||
public required int Turn { get; init; }
|
||||
public required Kind Type { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Core.Util;
|
||||
// NOTE: deliberately NOT importing Theriapolis.Core.Rules.Character because
|
||||
// the namespace name collides with the Character class inside it. Fully
|
||||
// qualify Character; use Allegiance via Rules.Character.Allegiance below.
|
||||
using Allegiance = Theriapolis.Core.Rules.Character.Allegiance;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime adapter the resolver works with. Wraps either a
|
||||
/// <see cref="Character"/> (player + future allies) or an
|
||||
/// <see cref="NpcTemplateDef"/> (NPCs spawned from chunk lists). Carries
|
||||
/// the mutable per-encounter state — HP, position, conditions — so the
|
||||
/// source records aren't touched until the encounter ends and results
|
||||
/// are written back.
|
||||
/// </summary>
|
||||
public sealed class Combatant
|
||||
{
|
||||
public int Id { get; }
|
||||
public string Name { get; }
|
||||
public Allegiance Allegiance { get; }
|
||||
public SizeCategory Size { get; }
|
||||
public AbilityScores Abilities { get; }
|
||||
public int ProficiencyBonus { get; }
|
||||
public int ArmorClass { get; }
|
||||
public int MaxHp { get; }
|
||||
public int SpeedFt { get; }
|
||||
public int InitiativeBonus { get; }
|
||||
public IReadOnlyList<AttackOption> AttackOptions { get; }
|
||||
|
||||
/// <summary>Source <see cref="Character"/> if built from one (player or ally). Null for NPC-template combatants.</summary>
|
||||
public Theriapolis.Core.Rules.Character.Character? SourceCharacter { get; }
|
||||
/// <summary>Source <see cref="NpcTemplateDef"/> if built from one. Null for character combatants.</summary>
|
||||
public NpcTemplateDef? SourceTemplate { get; }
|
||||
|
||||
// ── Mutable per-encounter state ───────────────────────────────────────
|
||||
public int CurrentHp { get; set; }
|
||||
public Vec2 Position { get; set; }
|
||||
public HashSet<Condition> Conditions { get; } = new();
|
||||
/// <summary>Phase 5 M6: present only on player combatants while at 0 HP. Tracks the death-save loop.</summary>
|
||||
public DeathSaveTracker? DeathSaves { get; set; }
|
||||
|
||||
// ── Phase 5 M6: per-encounter feature flags ──────────────────────────
|
||||
/// <summary>True while Feral Rage is active. Bonus action toggle.</summary>
|
||||
public bool RageActive { get; set; }
|
||||
/// <summary>True while Bulwark Sentinel Stance is active. Halves speed; +2 AC.</summary>
|
||||
public bool SentinelStanceActive { get; set; }
|
||||
/// <summary>Set when Sneak Attack damage has fired this turn — once-per-turn limit.</summary>
|
||||
public bool SneakAttackUsedThisTurn { get; set; }
|
||||
|
||||
// ── Phase 6.5 M1: per-encounter feature state ───────────────────────
|
||||
/// <summary>
|
||||
/// Pending Vocalization-Dice inspiration die granted by a Muzzle-Speaker.
|
||||
/// 0 = none. When non-zero, the next attack/check/save this combatant
|
||||
/// rolls adds 1d<value> to the result; the field then resets to 0.
|
||||
/// Sides match the Vocalization Dice ladder: 6 / 8 / 10 / 12.
|
||||
/// </summary>
|
||||
public int InspirationDieSides { get; set; }
|
||||
|
||||
// ── Phase 6.5 M2: subclass-feature per-encounter state ───────────────
|
||||
/// <summary>
|
||||
/// Phase 6.5 M2 — Pack-Forged "Packmate's Howl" mark. Set on the target
|
||||
/// when a Pack-Forged Fangsworn lands a melee hit; the next attack by
|
||||
/// any *ally* of the Pack-Forged on this target gains advantage. The
|
||||
/// mark expires when the marker's turn comes around again — tracked
|
||||
/// here as the round number the mark was placed; resolver checks
|
||||
/// <c>currentRound == HowlMarkRound + 0</c> (current round) or
|
||||
/// <c>currentRound == HowlMarkRound + 1</c> (next round, before
|
||||
/// marker's turn). Cleared on consume.
|
||||
/// </summary>
|
||||
public int? HowlMarkRound { get; set; }
|
||||
/// <summary>The Pack-Forged combatant id that placed the howl mark.</summary>
|
||||
public int? HowlMarkBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M2 — Blood Memory "Predatory Surge" trigger. Set when this
|
||||
/// raging Feral kills a creature with a melee attack; consumed by the
|
||||
/// HUD on the next bonus-action prompt (free extra melee attack).
|
||||
/// </summary>
|
||||
public bool PredatorySurgePending { get; set; }
|
||||
|
||||
// ── Phase 6.5 M3: Covenant Authority oath mark ───────────────────────
|
||||
/// <summary>
|
||||
/// Round number when an oath was placed on this combatant (Covenant-
|
||||
/// Keeper Covenant's Authority). While the mark is live, the combatant
|
||||
/// suffers -2 to attack rolls vs. its marker. Expires 10 rounds after
|
||||
/// placement (= 1 minute in d20 round time).
|
||||
/// </summary>
|
||||
public int? OathMarkRound { get; set; }
|
||||
|
||||
/// <summary>The Covenant-Keeper combatant id who placed the oath mark.</summary>
|
||||
public int? OathMarkBy { get; set; }
|
||||
|
||||
// ── Phase 7 M0: subclass per-turn / per-encounter flags ──────────────
|
||||
/// <summary>
|
||||
/// Phase 7 M0 — Stampede-Heart "Trampling Charge". Set when this turn's
|
||||
/// first melee attack adds the +1d8 bludgeoning bonus; prevents the
|
||||
/// bonus from firing twice in one turn. Resets at turn start.
|
||||
/// </summary>
|
||||
public bool TramplingChargeUsedThisTurn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M0 — Ambush-Artist "Opening Strike". Set after the
|
||||
/// first melee attack in this encounter consumes the +2d6 bonus; the
|
||||
/// bonus only fires once per encounter. Lasts the encounter
|
||||
/// (no per-turn reset).
|
||||
/// </summary>
|
||||
public bool OpeningStrikeUsed { get; set; }
|
||||
|
||||
/// <summary>Reset per-turn flags. Called by Encounter.EndTurn for the incoming actor.</summary>
|
||||
public void OnTurnStart()
|
||||
{
|
||||
SneakAttackUsedThisTurn = false;
|
||||
TramplingChargeUsedThisTurn = false;
|
||||
}
|
||||
|
||||
/// <summary>True once HP ≤ 0. NPC-template combatants are removed from initiative; character combatants enter death-save mode.</summary>
|
||||
public bool IsDown => CurrentHp <= 0;
|
||||
/// <summary>True if either alive (HP > 0) or downed-but-not-dead (rolling death saves).</summary>
|
||||
public bool IsAlive => !IsDown || (DeathSaves is not null && !DeathSaves.Dead);
|
||||
|
||||
private Combatant(
|
||||
int id, string name, Allegiance allegiance,
|
||||
SizeCategory size, AbilityScores abilities, int profBonus,
|
||||
int armorClass, int maxHp, int speedFt, int initiativeBonus,
|
||||
IReadOnlyList<AttackOption> attacks,
|
||||
Theriapolis.Core.Rules.Character.Character? sourceCharacter, NpcTemplateDef? sourceTemplate,
|
||||
Vec2 position)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
Allegiance = allegiance;
|
||||
Size = size;
|
||||
Abilities = abilities;
|
||||
ProficiencyBonus= profBonus;
|
||||
ArmorClass = armorClass;
|
||||
MaxHp = maxHp;
|
||||
SpeedFt = speedFt;
|
||||
InitiativeBonus = initiativeBonus;
|
||||
AttackOptions = attacks;
|
||||
SourceCharacter = sourceCharacter;
|
||||
SourceTemplate = sourceTemplate;
|
||||
CurrentHp = maxHp;
|
||||
Position = position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a combatant from a <see cref="Character"/>. Pulls AC, HP, and
|
||||
/// the primary attack from equipped MainHand (or unarmed strike if none).
|
||||
/// </summary>
|
||||
public static Combatant FromCharacter(Theriapolis.Core.Rules.Character.Character c, int id, Vec2 position)
|
||||
{
|
||||
int ac = DerivedStats.ArmorClass(c);
|
||||
int speed = DerivedStats.SpeedFt(c);
|
||||
int initBonus = DerivedStats.Initiative(c);
|
||||
var attacks = BuildCharacterAttacks(c);
|
||||
return new Combatant(
|
||||
id, c.Background?.Name is { Length: > 0 } ? $"PC-{id}" : $"PC-{id}",
|
||||
c.SourceCharacterAllegiance(), c.Size, c.Abilities, c.ProficiencyBonus,
|
||||
ac, c.MaxHp, speed, initBonus, attacks,
|
||||
sourceCharacter: c, sourceTemplate: null, position: position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a combatant from a <see cref="Character"/> with an explicit
|
||||
/// display name (typically the player's chosen name).
|
||||
/// </summary>
|
||||
public static Combatant FromCharacter(Theriapolis.Core.Rules.Character.Character c, int id, string name, Vec2 position, Allegiance allegiance)
|
||||
{
|
||||
int ac = DerivedStats.ArmorClass(c);
|
||||
int speed = DerivedStats.SpeedFt(c);
|
||||
int initBonus = DerivedStats.Initiative(c);
|
||||
var attacks = BuildCharacterAttacks(c);
|
||||
return new Combatant(
|
||||
id, name, allegiance, c.Size, c.Abilities, c.ProficiencyBonus,
|
||||
ac, c.MaxHp, speed, initBonus, attacks,
|
||||
sourceCharacter: c, sourceTemplate: null, position: position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a combatant from an NPC template. AC and HP come straight from
|
||||
/// the template; attacks are mapped 1:1 from <see cref="NpcTemplateDef.Attacks"/>.
|
||||
/// </summary>
|
||||
public static Combatant FromNpcTemplate(NpcTemplateDef def, int id, Vec2 position)
|
||||
{
|
||||
var size = SizeExtensions.FromJson(def.Size);
|
||||
var abilities = new AbilityScores(
|
||||
Score(def.AbilityScores, "STR", 10),
|
||||
Score(def.AbilityScores, "DEX", 10),
|
||||
Score(def.AbilityScores, "CON", 10),
|
||||
Score(def.AbilityScores, "INT", 10),
|
||||
Score(def.AbilityScores, "WIS", 10),
|
||||
Score(def.AbilityScores, "CHA", 10));
|
||||
// NPC profs default to +2 (CR ≤ 4 baseline).
|
||||
const int npcProf = 2;
|
||||
int initBonus = AbilityScores.Mod(abilities.DEX);
|
||||
var attacks = new List<AttackOption>(def.Attacks.Length);
|
||||
foreach (var atk in def.Attacks) attacks.Add(BuildNpcAttack(atk));
|
||||
// 5 ft. = 1 tactical tile; convert NPC speed_ft to tiles.
|
||||
int speedFt = def.SpeedFt;
|
||||
var allegiance = Theriapolis.Core.Rules.Character.AllegianceExtensions.FromJson(def.DefaultAllegiance);
|
||||
return new Combatant(
|
||||
id, def.Name, allegiance, size, abilities, npcProf,
|
||||
armorClass: def.Ac, maxHp: def.Hp, speedFt: speedFt, initiativeBonus: initBonus,
|
||||
attacks: attacks,
|
||||
sourceCharacter: null, sourceTemplate: def, position: position);
|
||||
}
|
||||
|
||||
/// <summary>Distance to another combatant in tactical tiles, edge-to-edge Chebyshev.</summary>
|
||||
public int DistanceTo(Combatant other) => ReachAndCover.EdgeToEdgeChebyshev(this, other);
|
||||
|
||||
private static int Score(IReadOnlyDictionary<string, int> dict, string key, int fallback)
|
||||
=> dict.TryGetValue(key, out int v) ? v : fallback;
|
||||
|
||||
/// <summary>Builds the attack option list for a character: equipped weapon if any, else an unarmed strike.</summary>
|
||||
private static List<AttackOption> BuildCharacterAttacks(Theriapolis.Core.Rules.Character.Character c)
|
||||
{
|
||||
var list = new List<AttackOption>();
|
||||
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
|
||||
if (main is not null && string.Equals(main.Def.Kind, "weapon", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
list.Add(BuildWeaponAttack(c, main.Def));
|
||||
}
|
||||
else
|
||||
{
|
||||
list.Add(BuildUnarmedStrike(c));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static AttackOption BuildWeaponAttack(Theriapolis.Core.Rules.Character.Character c, ItemDef weapon)
|
||||
{
|
||||
// Finesse weapons use the higher of STR/DEX; ranged weapons use DEX.
|
||||
bool isFinesse = HasProperty(weapon, "finesse");
|
||||
bool isRanged = weapon.RangeShortTiles > 0 || HasProperty(weapon, "ammunition") || HasProperty(weapon, "thrown");
|
||||
AbilityId abil = isRanged
|
||||
? AbilityId.DEX
|
||||
: (isFinesse
|
||||
? (c.Abilities.ModFor(AbilityId.STR) >= c.Abilities.ModFor(AbilityId.DEX)
|
||||
? AbilityId.STR : AbilityId.DEX)
|
||||
: AbilityId.STR);
|
||||
int abilMod = c.Abilities.ModFor(abil);
|
||||
// Proficiency: assume the character is proficient with all weapons their class lists.
|
||||
// For Phase 5 M4 we apply proficiency unconditionally (every combat-touching class
|
||||
// is proficient with their starting weapon). Wrong-proficiency disadvantage lands in M6.
|
||||
int toHit = c.ProficiencyBonus + abilMod;
|
||||
|
||||
var damage = DamageRoll.Parse(
|
||||
string.IsNullOrEmpty(weapon.Damage) ? "1d4" : weapon.Damage,
|
||||
string.IsNullOrEmpty(weapon.DamageType)
|
||||
? DamageType.Bludgeoning
|
||||
: DamageTypeExtensions.FromJson(weapon.DamageType));
|
||||
damage = damage with { FlatMod = damage.FlatMod + abilMod };
|
||||
|
||||
int reach = weapon.ReachTiles > 0 ? weapon.ReachTiles : c.Size.DefaultReachTiles();
|
||||
|
||||
return new AttackOption
|
||||
{
|
||||
Name = weapon.Name,
|
||||
ToHitBonus = toHit,
|
||||
Damage = damage,
|
||||
ReachTiles = isRanged ? 0 : reach,
|
||||
RangeShortTiles = isRanged ? (weapon.RangeShortTiles > 0 ? weapon.RangeShortTiles : 6) : 0,
|
||||
RangeLongTiles = isRanged ? (weapon.RangeLongTiles > 0 ? weapon.RangeLongTiles : 24) : 0,
|
||||
CritOnNatural = 20,
|
||||
};
|
||||
}
|
||||
|
||||
private static AttackOption BuildUnarmedStrike(Theriapolis.Core.Rules.Character.Character c)
|
||||
{
|
||||
int strMod = c.Abilities.ModFor(AbilityId.STR);
|
||||
int toHit = c.ProficiencyBonus + strMod;
|
||||
return new AttackOption
|
||||
{
|
||||
Name = "Unarmed Strike",
|
||||
ToHitBonus = toHit,
|
||||
Damage = new DamageRoll(0, 0, System.Math.Max(1, 1 + strMod), DamageType.Bludgeoning),
|
||||
ReachTiles = c.Size.DefaultReachTiles(),
|
||||
CritOnNatural = 20,
|
||||
};
|
||||
}
|
||||
|
||||
private static AttackOption BuildNpcAttack(NpcAttack atk)
|
||||
{
|
||||
var dmg = DamageRoll.Parse(
|
||||
string.IsNullOrEmpty(atk.Damage) ? "1d4" : atk.Damage,
|
||||
string.IsNullOrEmpty(atk.DamageType)
|
||||
? DamageType.Bludgeoning
|
||||
: DamageTypeExtensions.FromJson(atk.DamageType));
|
||||
return new AttackOption
|
||||
{
|
||||
Name = atk.Name,
|
||||
ToHitBonus = atk.ToHit,
|
||||
Damage = dmg,
|
||||
ReachTiles = atk.ReachTiles > 0 ? atk.ReachTiles : 1,
|
||||
RangeShortTiles = atk.RangeShortTiles,
|
||||
RangeLongTiles = atk.RangeLongTiles,
|
||||
CritOnNatural = 20,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasProperty(ItemDef def, string prop)
|
||||
{
|
||||
foreach (var p in def.Properties)
|
||||
if (string.Equals(p, prop, System.StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Convenience extension so callers needn't know whether a Character has Allegiance attached.</summary>
|
||||
internal static class CharacterCombatExtensions
|
||||
{
|
||||
public static Allegiance SourceCharacterAllegiance(this Theriapolis.Core.Rules.Character.Character _)
|
||||
=> Allegiance.Player;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed damage expression: <c>NdM+B</c> where N = dice count, M = die
|
||||
/// sides, B = flat modifier (can be negative). Examples: "1d6", "2d8+2",
|
||||
/// "1d4-1". <see cref="Roll"/> takes a function that returns 1..M for each
|
||||
/// dice and aggregates with the flat modifier.
|
||||
/// </summary>
|
||||
public sealed record DamageRoll(int DiceCount, int DiceSides, int FlatMod, DamageType DamageType)
|
||||
{
|
||||
/// <summary>
|
||||
/// Roll the damage dice. <paramref name="rollDie"/> takes the die size
|
||||
/// (e.g. 6) and returns 1..size. On crit, dice double per d20 rules
|
||||
/// (the flat modifier does NOT double).
|
||||
/// </summary>
|
||||
public int Roll(System.Func<int, int> rollDie, bool isCrit = false)
|
||||
{
|
||||
int diceToRoll = isCrit ? DiceCount * 2 : DiceCount;
|
||||
int total = FlatMod;
|
||||
for (int i = 0; i < diceToRoll; i++)
|
||||
total += rollDie(DiceSides);
|
||||
return System.Math.Max(0, total);
|
||||
}
|
||||
|
||||
/// <summary>Theoretical maximum (every die rolls its top face) + flat mod.</summary>
|
||||
public int Max(bool isCrit = false)
|
||||
{
|
||||
int dice = isCrit ? DiceCount * 2 : DiceCount;
|
||||
return dice * DiceSides + FlatMod;
|
||||
}
|
||||
|
||||
/// <summary>Theoretical minimum (every die rolls 1) + flat mod, clamped to 0.</summary>
|
||||
public int Min(bool isCrit = false)
|
||||
{
|
||||
int dice = isCrit ? DiceCount * 2 : DiceCount;
|
||||
return System.Math.Max(0, dice * 1 + FlatMod);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
string mod = FlatMod == 0 ? "" : (FlatMod > 0 ? $"+{FlatMod}" : $"{FlatMod}");
|
||||
return $"{DiceCount}d{DiceSides}{mod} {DamageType.ToString().ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an expression like "1d6", "2d8+2", "1d4-1", "5" (flat 5),
|
||||
/// or "0" (no damage). Whitespace is allowed. Throws on malformed input.
|
||||
/// </summary>
|
||||
public static DamageRoll Parse(string expr, DamageType damageType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expr))
|
||||
throw new System.ArgumentException("Damage expression is empty", nameof(expr));
|
||||
|
||||
string s = expr.Replace(" ", "").ToLowerInvariant();
|
||||
int dIdx = s.IndexOf('d');
|
||||
if (dIdx < 0)
|
||||
{
|
||||
// No dice — pure flat (e.g. "5" or "-1").
|
||||
if (!int.TryParse(s, out int flat))
|
||||
throw new System.FormatException($"Cannot parse damage '{expr}' as flat int.");
|
||||
return new DamageRoll(0, 0, flat, damageType);
|
||||
}
|
||||
|
||||
// Split into "<count>" "d" "<sides>[modifier]"
|
||||
string countStr = s.Substring(0, dIdx);
|
||||
if (countStr.Length == 0) countStr = "1"; // "d6" → 1d6
|
||||
if (!int.TryParse(countStr, out int diceCount))
|
||||
throw new System.FormatException($"Bad dice count in '{expr}'");
|
||||
|
||||
string rest = s.Substring(dIdx + 1);
|
||||
int signIdx = -1;
|
||||
for (int i = 0; i < rest.Length; i++)
|
||||
{
|
||||
if (rest[i] == '+' || rest[i] == '-') { signIdx = i; break; }
|
||||
}
|
||||
|
||||
int sides;
|
||||
int flatMod;
|
||||
if (signIdx < 0)
|
||||
{
|
||||
if (!int.TryParse(rest, out sides))
|
||||
throw new System.FormatException($"Bad dice sides in '{expr}'");
|
||||
flatMod = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!int.TryParse(rest.Substring(0, signIdx), out sides))
|
||||
throw new System.FormatException($"Bad dice sides in '{expr}'");
|
||||
if (!int.TryParse(rest.Substring(signIdx), out flatMod))
|
||||
throw new System.FormatException($"Bad flat mod in '{expr}'");
|
||||
}
|
||||
|
||||
if (diceCount < 0 || sides < 0)
|
||||
throw new System.FormatException($"Negative dice count or sides in '{expr}'");
|
||||
|
||||
return new DamageRoll(diceCount, sides, flatMod, damageType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M6 player death-save loop. d20 every turn while at 0 HP:
|
||||
/// - 1 → 2 failures
|
||||
/// - 2..9 → 1 failure
|
||||
/// - 10..19 → 1 success
|
||||
/// - 20 → revive at 1 HP (zero out failures + successes)
|
||||
///
|
||||
/// 3 cumulative successes (≥10) → stabilised at 0 HP (cleared on heal).
|
||||
/// 3 cumulative failures (<10) → dead. CombatHUDScreen pushes
|
||||
/// <see cref="Game.Screens.DefeatedScreen"/> when this fires.
|
||||
///
|
||||
/// Tracker lives on <see cref="Combatant"/> only for the player; NPC
|
||||
/// combatants skip death saves and are removed at 0 HP.
|
||||
/// </summary>
|
||||
public sealed class DeathSaveTracker
|
||||
{
|
||||
public int Successes { get; private set; }
|
||||
public int Failures { get; private set; }
|
||||
public bool Stabilised { get; private set; }
|
||||
public bool Dead { get; private set; }
|
||||
|
||||
/// <summary>Roll a death save and update counters. Returns the outcome.</summary>
|
||||
public DeathSaveOutcome Roll(Encounter enc, Combatant target)
|
||||
{
|
||||
if (Dead || Stabilised) return DeathSaveOutcome.NoOp;
|
||||
|
||||
int d20 = enc.RollD20();
|
||||
DeathSaveOutcome outcome;
|
||||
if (d20 == 20)
|
||||
{
|
||||
// Critical success — revive at 1 HP.
|
||||
target.CurrentHp = 1;
|
||||
target.Conditions.Remove(Condition.Unconscious);
|
||||
Successes = 0;
|
||||
Failures = 0;
|
||||
outcome = DeathSaveOutcome.CriticalRevive;
|
||||
}
|
||||
else if (d20 >= 10)
|
||||
{
|
||||
Successes++;
|
||||
outcome = Successes >= 3 ? DeathSaveOutcome.Stabilised : DeathSaveOutcome.Success;
|
||||
if (Successes >= 3) Stabilised = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
int failsThisRoll = d20 == 1 ? 2 : 1;
|
||||
Failures += failsThisRoll;
|
||||
outcome = Failures >= 3 ? DeathSaveOutcome.Dead : DeathSaveOutcome.Failure;
|
||||
if (Failures >= 3) Dead = true;
|
||||
}
|
||||
|
||||
enc.AppendLog(CombatLogEntry.Kind.Save,
|
||||
$"{target.Name} death save: {d20} → {outcome} ({Successes}S/{Failures}F)");
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/// <summary>Called when the character is healed above 0 HP — cancels the loop.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
Successes = 0;
|
||||
Failures = 0;
|
||||
Stabilised = false;
|
||||
// Don't reset Dead — once dead, stays dead.
|
||||
}
|
||||
}
|
||||
|
||||
public enum DeathSaveOutcome
|
||||
{
|
||||
NoOp = 0,
|
||||
Success = 1,
|
||||
Failure = 2,
|
||||
Stabilised = 3,
|
||||
Dead = 4,
|
||||
CriticalRevive = 5,
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// One combat encounter. Owns the participants, initiative order, current
|
||||
/// turn pointer, log, and a per-encounter <see cref="SeededRng"/> seeded
|
||||
/// from <c>worldSeed ^ C.RNG_COMBAT ^ encounterId</c>. Save/load can resume
|
||||
/// mid-combat by capturing <see cref="EncounterSeed"/> +
|
||||
/// <see cref="RollCount"/> and replaying the dice stream from the same
|
||||
/// sequence point — see <see cref="ResumeRolls"/>.
|
||||
/// </summary>
|
||||
public sealed class Encounter
|
||||
{
|
||||
public ulong EncounterId { get; }
|
||||
public ulong EncounterSeed { get; }
|
||||
public IReadOnlyList<Combatant> Participants => _participants;
|
||||
public IReadOnlyList<int> InitiativeOrder => _initiativeOrder;
|
||||
public int CurrentTurnIndex { get; private set; }
|
||||
public int RoundNumber { get; private set; } = 1;
|
||||
public Turn CurrentTurn { get; private set; }
|
||||
public IReadOnlyList<CombatLogEntry> Log => _log;
|
||||
public bool IsOver => _isOver;
|
||||
|
||||
/// <summary>How many dice rolls have been drawn from this encounter's RNG.</summary>
|
||||
public int RollCount { get; private set; }
|
||||
|
||||
private readonly List<Combatant> _participants;
|
||||
private readonly List<int> _initiativeOrder;
|
||||
private readonly List<CombatLogEntry> _log = new();
|
||||
private SeededRng _rng;
|
||||
private bool _isOver;
|
||||
|
||||
public Encounter(ulong worldSeed, ulong encounterId, IEnumerable<Combatant> combatants)
|
||||
{
|
||||
EncounterId = encounterId;
|
||||
EncounterSeed = worldSeed ^ C.RNG_COMBAT ^ encounterId;
|
||||
_rng = new SeededRng(EncounterSeed);
|
||||
_participants = new List<Combatant>(combatants);
|
||||
if (_participants.Count == 0)
|
||||
throw new System.ArgumentException("Encounter requires at least one combatant.", nameof(combatants));
|
||||
|
||||
_initiativeOrder = RollInitiative();
|
||||
CurrentTurnIndex = 0;
|
||||
CurrentTurn = Turn.FreshFor(CurrentActor.Id, CurrentActor.SpeedFt);
|
||||
AppendLog(CombatLogEntry.Kind.Initiative, FormatInitiativeOrder());
|
||||
AppendLog(CombatLogEntry.Kind.TurnStart, $"Round 1 — {CurrentActor.Name}'s turn.");
|
||||
}
|
||||
|
||||
public Combatant CurrentActor => _participants[_initiativeOrder[CurrentTurnIndex]];
|
||||
|
||||
public Combatant? GetById(int id)
|
||||
{
|
||||
foreach (var c in _participants) if (c.Id == id) return c;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances to the next living combatant. Wraps the round counter when
|
||||
/// we cycle past the last initiative slot. Marks the encounter over if
|
||||
/// only one allegiance has living combatants.
|
||||
/// </summary>
|
||||
public void EndTurn()
|
||||
{
|
||||
if (_isOver) return;
|
||||
|
||||
int n = _initiativeOrder.Count;
|
||||
for (int step = 0; step < n; step++)
|
||||
{
|
||||
CurrentTurnIndex++;
|
||||
if (CurrentTurnIndex >= n)
|
||||
{
|
||||
CurrentTurnIndex = 0;
|
||||
RoundNumber++;
|
||||
}
|
||||
var next = CurrentActor;
|
||||
if (next.IsAlive)
|
||||
{
|
||||
CurrentTurn = Turn.FreshFor(next.Id, next.SpeedFt);
|
||||
next.OnTurnStart(); // Phase 5 M6: reset per-turn feature flags (Sneak Attack)
|
||||
AppendLog(CombatLogEntry.Kind.TurnStart, $"Round {RoundNumber} — {next.Name}'s turn.");
|
||||
CheckForVictory();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// No one is alive.
|
||||
EndEncounter("No combatants remain.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true and ends the encounter if only one allegiance has
|
||||
/// living combatants left. Called automatically at end-of-turn.
|
||||
/// </summary>
|
||||
public bool CheckForVictory()
|
||||
{
|
||||
var living = new HashSet<Rules.Character.Allegiance>();
|
||||
foreach (var c in _participants)
|
||||
if (c.IsAlive && !c.IsDown) living.Add(c.Allegiance);
|
||||
|
||||
// Allies and Players count as the same side for victory purposes.
|
||||
bool playerSide = living.Contains(Rules.Character.Allegiance.Player) || living.Contains(Rules.Character.Allegiance.Allied);
|
||||
bool hostileSide = living.Contains(Rules.Character.Allegiance.Hostile);
|
||||
|
||||
if (!playerSide || !hostileSide)
|
||||
{
|
||||
string verdict = playerSide ? "Player side wins." : (hostileSide ? "Hostile side wins." : "Mutual annihilation.");
|
||||
EndEncounter(verdict);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void EndEncounter(string verdict)
|
||||
{
|
||||
_isOver = true;
|
||||
AppendLog(CombatLogEntry.Kind.EncounterEnd, $"Encounter ends after {RoundNumber} round(s). {verdict}");
|
||||
}
|
||||
|
||||
// ── Dice ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Draw a uniform integer in [1, sides]. Increments
|
||||
/// <see cref="RollCount"/>; save/load uses that count to resume.
|
||||
/// </summary>
|
||||
public int RollDie(int sides)
|
||||
{
|
||||
if (sides < 1) return 0;
|
||||
RollCount++;
|
||||
return (int)(_rng.NextUInt64() % (ulong)sides) + 1;
|
||||
}
|
||||
|
||||
public int RollD20() => RollDie(20);
|
||||
|
||||
/// <summary>
|
||||
/// Roll d20 with advantage (best of two) or disadvantage (worst of two).
|
||||
/// Returns (kept, other) so the caller can log both.
|
||||
/// </summary>
|
||||
public (int kept, int other) RollD20WithMode(SituationFlags flags)
|
||||
{
|
||||
if (flags.RollsAdvantage())
|
||||
{
|
||||
int a = RollD20();
|
||||
int b = RollD20();
|
||||
return a >= b ? (a, b) : (b, a);
|
||||
}
|
||||
if (flags.RollsDisadvantage())
|
||||
{
|
||||
int a = RollD20();
|
||||
int b = RollD20();
|
||||
return a <= b ? (a, b) : (b, a);
|
||||
}
|
||||
return (RollD20(), -1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-create the RNG and skip <paramref name="rollCount"/> rolls.
|
||||
/// Used by the save layer to resume mid-combat encounters: capture
|
||||
/// (encounterId, rollCount) on save; recreate Encounter with same
|
||||
/// participants and call ResumeRolls(savedRollCount) on load.
|
||||
/// </summary>
|
||||
public void ResumeRolls(int rollCount)
|
||||
{
|
||||
_rng = new SeededRng(EncounterSeed);
|
||||
for (int i = 0; i < rollCount; i++) _rng.NextUInt64();
|
||||
RollCount = rollCount;
|
||||
}
|
||||
|
||||
// ── Logging ───────────────────────────────────────────────────────────
|
||||
|
||||
public void AppendLog(CombatLogEntry.Kind kind, string message)
|
||||
{
|
||||
_log.Add(new CombatLogEntry
|
||||
{
|
||||
Round = RoundNumber,
|
||||
Turn = CurrentTurnIndex,
|
||||
Type = kind,
|
||||
Message = message,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initiative ────────────────────────────────────────────────────────
|
||||
|
||||
private List<int> RollInitiative()
|
||||
{
|
||||
var rolls = new (int idx, int total, int initBonus, int dexMod)[_participants.Count];
|
||||
for (int i = 0; i < _participants.Count; i++)
|
||||
{
|
||||
var c = _participants[i];
|
||||
int d20 = RollD20();
|
||||
rolls[i] = (i, d20 + c.InitiativeBonus, c.InitiativeBonus,
|
||||
Stats.AbilityScores.Mod(c.Abilities.DEX));
|
||||
}
|
||||
// Sort descending by total; ties broken by DEX mod descending; final tiebreaker by id ascending.
|
||||
System.Array.Sort(rolls, (a, b) =>
|
||||
{
|
||||
int byTotal = b.total.CompareTo(a.total);
|
||||
if (byTotal != 0) return byTotal;
|
||||
int byDex = b.dexMod.CompareTo(a.dexMod);
|
||||
if (byDex != 0) return byDex;
|
||||
return _participants[a.idx].Id.CompareTo(_participants[b.idx].Id);
|
||||
});
|
||||
var order = new List<int>(rolls.Length);
|
||||
foreach (var r in rolls) order.Add(r.idx);
|
||||
return order;
|
||||
}
|
||||
|
||||
private string FormatInitiativeOrder()
|
||||
{
|
||||
var parts = new List<string>(_initiativeOrder.Count);
|
||||
foreach (int idx in _initiativeOrder)
|
||||
{
|
||||
var c = _participants[idx];
|
||||
parts.Add($"{c.Name} (init+{c.InitiativeBonus})");
|
||||
}
|
||||
return "Initiative: " + string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Per-tick check used by <see cref="Game.Screens.PlayScreen"/>:
|
||||
/// "is there a hostile NPC within encounter trigger range that has line of
|
||||
/// sight?" Returns the closest qualifying actor (or null) so the caller can
|
||||
/// kick off an encounter.
|
||||
///
|
||||
/// Friendly / Neutral proximity is the same shape but uses a tighter radius
|
||||
/// — see <see cref="FindInteractCandidate"/>.
|
||||
/// </summary>
|
||||
public static class EncounterTrigger
|
||||
{
|
||||
/// <summary>
|
||||
/// Find the closest live <em>hostile</em> NPC within
|
||||
/// <see cref="C.ENCOUNTER_TRIGGER_TILES"/> of the player that the
|
||||
/// <paramref name="losBlocked"/> predicate can see (no blocking tile
|
||||
/// between). Returns null if none found.
|
||||
/// </summary>
|
||||
public static NpcActor? FindHostileTrigger(
|
||||
ActorManager actors,
|
||||
System.Func<int, int, bool>? losBlocked = null)
|
||||
{
|
||||
var player = actors.Player;
|
||||
if (player is null) return null;
|
||||
|
||||
var blocked = losBlocked ?? LineOfSight.AlwaysClear;
|
||||
NpcActor? best = null;
|
||||
int bestDistSq = C.ENCOUNTER_TRIGGER_TILES * C.ENCOUNTER_TRIGGER_TILES;
|
||||
|
||||
foreach (var npc in actors.Npcs)
|
||||
{
|
||||
if (!npc.IsAlive) continue;
|
||||
if (npc.Allegiance != Rules.Character.Allegiance.Hostile) continue;
|
||||
int distSq = ChebyshevDistSq(player.Position, npc.Position);
|
||||
if (distSq > bestDistSq) continue;
|
||||
if (!LineOfSight.HasLine(player.Position, npc.Position, blocked)) continue;
|
||||
if (best is null || distSq < bestDistSq) { best = npc; bestDistSq = distSq; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Friendly / Neutral NPCs within
|
||||
/// <see cref="C.INTERACT_PROMPT_TILES"/> of the player. The HUD shows
|
||||
/// "[F] Talk to ..." for the closest match.
|
||||
/// </summary>
|
||||
public static NpcActor? FindInteractCandidate(
|
||||
ActorManager actors,
|
||||
System.Func<int, int, bool>? losBlocked = null)
|
||||
{
|
||||
var player = actors.Player;
|
||||
if (player is null) return null;
|
||||
|
||||
var blocked = losBlocked ?? LineOfSight.AlwaysClear;
|
||||
NpcActor? best = null;
|
||||
int bestDistSq = C.INTERACT_PROMPT_TILES * C.INTERACT_PROMPT_TILES;
|
||||
|
||||
foreach (var npc in actors.Npcs)
|
||||
{
|
||||
if (!npc.IsAlive) continue;
|
||||
if (npc.Allegiance != Rules.Character.Allegiance.Friendly &&
|
||||
npc.Allegiance != Rules.Character.Allegiance.Neutral) continue;
|
||||
int distSq = ChebyshevDistSq(player.Position, npc.Position);
|
||||
if (distSq > bestDistSq) continue;
|
||||
if (!LineOfSight.HasLine(player.Position, npc.Position, blocked)) continue;
|
||||
if (best is null || distSq < bestDistSq) { best = npc; bestDistSq = distSq; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static int ChebyshevDistSq(Vec2 a, Vec2 b)
|
||||
{
|
||||
int dx = (int)System.Math.Abs(a.X - b.X);
|
||||
int dy = (int)System.Math.Abs(a.Y - b.Y);
|
||||
int d = System.Math.Max(dx, dy);
|
||||
return d * d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M6: dispatches class-feature combat effects at hook points the
|
||||
/// resolver and DerivedStats call into. Hand-coded switch-on-class-id; the
|
||||
/// alternative would be a feature-registration system — overkill for the
|
||||
/// half-dozen combat-touching level-1 features we actually ship.
|
||||
///
|
||||
/// Implemented features:
|
||||
/// - Fangsworn fighting styles: Duelist (+2 dmg one-handed), Great Weapon (re-roll 1s/2s on dmg)
|
||||
/// - Feral: Unarmored Defense (10 + DEX + CON when no body armor), Feral Rage (+2 dmg, resistance)
|
||||
/// - Bulwark: Sentinel Stance (+2 AC), Guardian's Mark (UI hook only — full effect M6.5)
|
||||
/// - Shadow-Pelt: Sneak Attack (+1d6 first hit per turn with finesse/ranged weapon)
|
||||
///
|
||||
/// Stubs (no combat effect at M6 — flagged for later wiring):
|
||||
/// - Scent-Broker, Covenant-Keeper, Muzzle-Speaker, Claw-Wright level-1
|
||||
/// features. They appear in level_table but don't alter dice yet.
|
||||
/// </summary>
|
||||
public static class FeatureProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the raw AC for a character, factoring in class features.
|
||||
/// Called by <see cref="DerivedStats.ArmorClass"/> *after* the standard
|
||||
/// armor/shield/DEX computation so this layer can either replace
|
||||
/// (Unarmored Defense) or add (Sentinel Stance) to the base.
|
||||
///
|
||||
/// Returns the *new* AC value to use; pass back <paramref name="baseAc"/>
|
||||
/// when no feature applies.
|
||||
/// </summary>
|
||||
public static int ApplyAcFeatures(Theriapolis.Core.Rules.Character.Character c, int baseAc)
|
||||
{
|
||||
int ac = baseAc;
|
||||
// Feral Unarmored Defense replaces base if no body armor.
|
||||
if (c.ClassDef.Id == "feral" && c.Inventory.GetEquipped(EquipSlot.Body) is null)
|
||||
{
|
||||
int dex = c.Abilities.ModFor(AbilityId.DEX);
|
||||
int con = c.Abilities.ModFor(AbilityId.CON);
|
||||
int unarmoredAc = 10 + dex + con;
|
||||
// Take whichever is higher — Feral may pick up a buckler offhand etc. that pushes baseAc higher.
|
||||
if (unarmoredAc > ac) ac = unarmoredAc;
|
||||
}
|
||||
return ac;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AC bonus from per-encounter combat-time features (Sentinel Stance, etc).
|
||||
/// Combat resolver adds this to the combatant's base AC at attack-resolution time.
|
||||
///
|
||||
/// Phase 6.5 M2 layers in subclass passive AC bonuses — caller passes
|
||||
/// the encounter so the resolver can consult positional state for
|
||||
/// adjacency-driven features (Herd-Wall Interlock Shields, Lone Fang
|
||||
/// Isolation Bonus).
|
||||
/// </summary>
|
||||
public static int ApplyAcBonus(Combatant target, Encounter? enc = null)
|
||||
{
|
||||
int bonus = 0;
|
||||
if (target.SentinelStanceActive) bonus += 2;
|
||||
|
||||
// Phase 6.5 M2 subclass passives.
|
||||
var c = target.SourceCharacter;
|
||||
if (c is not null && enc is not null && !string.IsNullOrEmpty(c.SubclassId))
|
||||
{
|
||||
switch (c.SubclassId)
|
||||
{
|
||||
case "lone_fang":
|
||||
if (HasLoneFangIsolation(target, enc)) bonus += 1;
|
||||
break;
|
||||
case "herd_wall":
|
||||
if (HasHerdWallAdjacentAlly(target, enc)) bonus += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return bonus;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M2 — to-hit bonus from subclass features that boost
|
||||
/// attack rolls (e.g. Lone Fang Isolation Bonus). Resolver adds this
|
||||
/// to <c>attackTotal</c> alongside the base attack bonus.
|
||||
/// </summary>
|
||||
public static int ApplyToHitBonus(Combatant attacker, Encounter enc)
|
||||
{
|
||||
int bonus = 0;
|
||||
var c = attacker.SourceCharacter;
|
||||
if (c is null || string.IsNullOrEmpty(c.SubclassId)) return 0;
|
||||
switch (c.SubclassId)
|
||||
{
|
||||
case "lone_fang":
|
||||
if (HasLoneFangIsolation(attacker, enc)) bonus += 2;
|
||||
break;
|
||||
}
|
||||
return bonus;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the Lone Fang's "Isolation Bonus" applies — no allied
|
||||
/// combatant within 10 ft. <see cref="ReachAndCover.EdgeToEdgeChebyshev"/>
|
||||
/// returns the number of *empty tiles between* two footprints, so:
|
||||
/// 0 = touching (5 ft. away), 1 = one empty tile (10 ft.), etc.
|
||||
/// "Within 10 ft" means edge-to-edge ≤ 1.
|
||||
/// </summary>
|
||||
private static bool HasLoneFangIsolation(Combatant self, Encounter enc)
|
||||
{
|
||||
foreach (var c in enc.Participants)
|
||||
{
|
||||
if (c.Id == self.Id) continue;
|
||||
if (c.IsDown) continue;
|
||||
bool sameSide = (self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|
||||
|| self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
|
||||
&& (c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|
||||
|| c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
|
||||
if (!sameSide) continue;
|
||||
if (ReachAndCover.EdgeToEdgeChebyshev(self, c) <= 1) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the Herd-Wall has at least one allied combatant adjacent.
|
||||
/// "Adjacent" in the d20 sense = sharing an edge or corner; with the
|
||||
/// edge-to-edge "empty tiles between" metric that's distance 0.
|
||||
/// </summary>
|
||||
private static bool HasHerdWallAdjacentAlly(Combatant self, Encounter enc)
|
||||
{
|
||||
foreach (var c in enc.Participants)
|
||||
{
|
||||
if (c.Id == self.Id) continue;
|
||||
if (c.IsDown) continue;
|
||||
bool sameSide = (self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|
||||
|| self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
|
||||
&& (c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|
||||
|| c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
|
||||
if (!sameSide) continue;
|
||||
if (ReachAndCover.EdgeToEdgeChebyshev(self, c) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M2 — Pack-Forged "Packmate's Howl". Called from the
|
||||
/// resolver when a Pack-Forged hits a target with a melee attack:
|
||||
/// marks the target so the next *ally* attack against it gains
|
||||
/// advantage (until the marker's next turn).
|
||||
/// </summary>
|
||||
public static void OnPackForgedHit(Encounter enc, Combatant attacker, Combatant target, AttackOption attack)
|
||||
{
|
||||
var c = attacker.SourceCharacter;
|
||||
if (c is null || c.SubclassId != "pack_forged") return;
|
||||
if (attack.IsRanged) return; // melee only per the description
|
||||
target.HowlMarkRound = enc.RoundNumber;
|
||||
target.HowlMarkBy = attacker.Id;
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$" Packmate's Howl: {target.Name} marked — next ally attack has advantage.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M2 — Pack-Forged consumption hook. If the target carries
|
||||
/// a Howl mark from one of the attacker's *allies* (not self), and the
|
||||
/// mark hasn't expired, returns true (advantage on this attack) and
|
||||
/// clears the mark. Resolver calls this before rolling the d20.
|
||||
/// </summary>
|
||||
public static bool ConsumeHowlAdvantage(Encounter enc, Combatant attacker, Combatant target)
|
||||
{
|
||||
if (target.HowlMarkRound is not int markRound) return false;
|
||||
if (target.HowlMarkBy is not int markBy) return false;
|
||||
if (markBy == attacker.Id) return false; // can't consume your own mark
|
||||
// Mark expires once the marker's next turn begins. Approximation: a
|
||||
// mark placed on round N consumed on round N or N+1 (before marker
|
||||
// gets to act) is valid; round > markRound + 1 = expired.
|
||||
if (enc.RoundNumber > markRound + 1) return false;
|
||||
// Allies only: the marker must be on the same side as the attacker.
|
||||
var marker = enc.GetById(markBy);
|
||||
if (marker is null) return false;
|
||||
bool sameSide = (attacker.Allegiance == marker.Allegiance)
|
||||
|| (attacker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|
||||
&& marker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
|
||||
|| (attacker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied
|
||||
&& marker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player);
|
||||
if (!sameSide) return false;
|
||||
// Consume.
|
||||
target.HowlMarkRound = null;
|
||||
target.HowlMarkBy = null;
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$" {attacker.Name} consumes Packmate's Howl — advantage on this attack.");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M2 — Blood Memory "Predatory Surge". Called by the
|
||||
/// resolver when a raging Feral with this subclass reduces a target
|
||||
/// to 0 HP with a melee attack. Sets the surge-pending flag; the HUD
|
||||
/// can offer the player a free bonus melee attack (M2 wires the flag;
|
||||
/// the bonus-action consumption is the player's job via the existing
|
||||
/// attack input).
|
||||
/// </summary>
|
||||
public static void OnBloodMemoryKill(Encounter enc, Combatant attacker, AttackOption attack)
|
||||
{
|
||||
var c = attacker.SourceCharacter;
|
||||
if (c is null || c.SubclassId != "blood_memory") return;
|
||||
if (!attacker.RageActive) return;
|
||||
if (attack.IsRanged) return;
|
||||
attacker.PredatorySurgePending = true;
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$" Predatory Surge: {attacker.Name} can take a free melee attack.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Damage bonus from feature effects (Fighting Style, Rage, Sneak Attack).
|
||||
/// Returns extra damage to add to the rolled total. Side effects: marks
|
||||
/// <see cref="Combatant.SneakAttackUsedThisTurn"/> when sneak attack fires.
|
||||
/// </summary>
|
||||
public static int ApplyDamageBonus(
|
||||
Encounter enc,
|
||||
Combatant attacker,
|
||||
Combatant target,
|
||||
AttackOption attack,
|
||||
bool isCrit)
|
||||
{
|
||||
int bonus = 0;
|
||||
var c = attacker.SourceCharacter;
|
||||
|
||||
// Feral Rage — +2 damage on melee attacks while raging.
|
||||
if (attacker.RageActive && !attack.IsRanged) bonus += 2;
|
||||
|
||||
// Fangsworn fighting styles.
|
||||
if (c is not null && c.ClassDef.Id == "fangsworn")
|
||||
{
|
||||
if (c.FightingStyle == "duelist" && IsOneHanded(c))
|
||||
bonus += 2;
|
||||
// Great Weapon and Natural Predator handled elsewhere (re-roll
|
||||
// and to-hit respectively).
|
||||
}
|
||||
|
||||
// Shadow-Pelt Sneak Attack — once per turn, +1d6 with finesse/ranged.
|
||||
if (c is not null && c.ClassDef.Id == "shadow_pelt"
|
||||
&& !attacker.SneakAttackUsedThisTurn
|
||||
&& IsFinesseOrRanged(attacker, attack))
|
||||
{
|
||||
int d6 = enc.RollDie(6);
|
||||
if (isCrit) d6 += enc.RollDie(6); // crit doubles the sneak attack die
|
||||
bonus += d6;
|
||||
attacker.SneakAttackUsedThisTurn = true;
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note, $" Sneak Attack: +{d6}");
|
||||
}
|
||||
|
||||
// Phase 7 M0 — Ambush-Artist "Opening Strike". First melee attack of
|
||||
// round 1 in the encounter (the "ambush" round) deals +2d6 sneak
|
||||
// damage. Stacks with base Sneak Attack — opening strike represents
|
||||
// a different surprise mechanism.
|
||||
if (c is not null && c.SubclassId == "ambush_artist"
|
||||
&& !attacker.OpeningStrikeUsed
|
||||
&& enc.RoundNumber == 1
|
||||
&& !attack.IsRanged)
|
||||
{
|
||||
int d6a = enc.RollDie(6);
|
||||
int d6b = enc.RollDie(6);
|
||||
int extra = d6a + d6b;
|
||||
if (isCrit) extra += enc.RollDie(6) + enc.RollDie(6);
|
||||
bonus += extra;
|
||||
attacker.OpeningStrikeUsed = true;
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note, $" Opening Strike: +{extra}");
|
||||
}
|
||||
|
||||
// Phase 7 M0 — Stampede-Heart "Trampling Charge". First melee attack
|
||||
// each turn while raging deals +1d8 bludgeoning. Phase 7 simplifies
|
||||
// the JSON's "moved 20+ ft. straight" geometry constraint to "first
|
||||
// melee attack while raging" — captures the spirit of the charge
|
||||
// without requiring a movement-vector tracker the tactical layer
|
||||
// doesn't yet expose. Phase 8 / 9 polish can refine.
|
||||
if (c is not null && c.SubclassId == "stampede_heart"
|
||||
&& attacker.RageActive
|
||||
&& !attacker.TramplingChargeUsedThisTurn
|
||||
&& !attack.IsRanged)
|
||||
{
|
||||
int d8 = enc.RollDie(8);
|
||||
if (isCrit) d8 += enc.RollDie(8);
|
||||
bonus += d8;
|
||||
attacker.TramplingChargeUsedThisTurn = true;
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note, $" Trampling Charge: +{d8}");
|
||||
}
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 M0 — Antler-Guard "Retaliatory Strike". Called from
|
||||
/// <see cref="Resolver.AttemptAttack"/> after damage applies on a melee
|
||||
/// hit. If the target is an Antler-Guard Bulwark in Sentinel Stance,
|
||||
/// the attacker takes 1d8 + CON (the target's CON) automatic damage.
|
||||
/// Phase 7 contract: deterrence-style return-damage, no save, no roll —
|
||||
/// the attack itself is the trigger. Doesn't fire on ranged attacks
|
||||
/// (the JSON specifies "from a melee attack").
|
||||
/// </summary>
|
||||
public static int OnAntlerGuardHit(Encounter enc, Combatant attacker, Combatant target, AttackOption attack)
|
||||
{
|
||||
var c = target.SourceCharacter;
|
||||
if (c is null || c.SubclassId != "antler_guard") return 0;
|
||||
if (!target.SentinelStanceActive) return 0;
|
||||
if (attack.IsRanged) return 0;
|
||||
// 1d8 + CON-mod return damage; min 1.
|
||||
int d8 = enc.RollDie(8);
|
||||
int con = AbilityScores.Mod(target.Abilities.Get(AbilityId.CON));
|
||||
int retaliation = System.Math.Max(1, d8 + con);
|
||||
Resolver.ApplyDamage(attacker, retaliation);
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$" Retaliatory Strike: {target.Name} returns {retaliation} ({d8}+{con}) to {attacker.Name}.");
|
||||
return retaliation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-roll 1s and 2s on damage dice for Fangsworn Great Weapon style.
|
||||
/// Called by DamageRoll.Roll only if the attacker has the style + a
|
||||
/// two-handed weapon. Returns the (possibly adjusted) dice value.
|
||||
/// </summary>
|
||||
public static int GreatWeaponReroll(Encounter enc, Combatant attacker, AttackOption attack, int rolledDie, int sides)
|
||||
{
|
||||
var c = attacker.SourceCharacter;
|
||||
if (c is null || c.ClassDef.Id != "fangsworn" || c.FightingStyle != "great_weapon") return rolledDie;
|
||||
if (!IsTwoHanded(c)) return rolledDie;
|
||||
if (rolledDie > 2) return rolledDie;
|
||||
// Re-roll once and take the new value (even if also 1 or 2).
|
||||
int rerolled = enc.RollDie(sides);
|
||||
return rerolled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if the damage type is fully resisted (half-damage). Phase 5 M6:
|
||||
/// Feral Rage gives resistance to bludgeoning/piercing/slashing while active.
|
||||
/// </summary>
|
||||
public static bool IsResisted(Combatant target, DamageType damageType)
|
||||
{
|
||||
if (target.RageActive)
|
||||
{
|
||||
return damageType == DamageType.Bludgeoning
|
||||
|| damageType == DamageType.Piercing
|
||||
|| damageType == DamageType.Slashing;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activate Feral Rage. Returns true if the rage started (had uses
|
||||
/// remaining); false if the character has no uses left.
|
||||
/// </summary>
|
||||
public static bool TryActivateRage(Encounter enc, Combatant attacker)
|
||||
{
|
||||
var c = attacker.SourceCharacter;
|
||||
if (c is null || c.ClassDef.Id != "feral") return false;
|
||||
if (attacker.RageActive) return false;
|
||||
if (c.RageUsesRemaining <= 0) return false;
|
||||
attacker.RageActive = true;
|
||||
c.RageUsesRemaining--;
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note, $"{attacker.Name} enters a rage. ({c.RageUsesRemaining} use(s) left)");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Toggle Bulwark Sentinel Stance.</summary>
|
||||
public static bool ToggleSentinelStance(Encounter enc, Combatant attacker)
|
||||
{
|
||||
var c = attacker.SourceCharacter;
|
||||
if (c is null || c.ClassDef.Id != "bulwark") return false;
|
||||
attacker.SentinelStanceActive = !attacker.SentinelStanceActive;
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{attacker.Name} {(attacker.SentinelStanceActive ? "enters" : "leaves")} Sentinel Stance.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Phase 6.5 M1: level-1 active class features ──────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Claw-Wright <c>field_repair</c>. Action; heals <c>1d8 + INT mod</c>
|
||||
/// HP to the target. Hybrid heal-target effectiveness (75%) applies if
|
||||
/// the target is a hybrid PC (Phase 6.5 M5 schema-stub for now — no
|
||||
/// hybrids exist yet, so the multiplier is gated by future data).
|
||||
/// </summary>
|
||||
public static bool TryFieldRepair(Encounter enc, Combatant healer, Combatant target)
|
||||
{
|
||||
var c = healer.SourceCharacter;
|
||||
if (c is null || c.ClassDef.Id != "claw_wright") return false;
|
||||
if (c.FieldRepairUsesRemaining <= 0)
|
||||
{
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note, $"{healer.Name}: Field Repair exhausted (rest to recover).");
|
||||
return false;
|
||||
}
|
||||
if (target.IsDown) return false;
|
||||
|
||||
int intMod = c.Abilities.ModFor(AbilityId.INT);
|
||||
// Phase 7 M0 — Body-Wright "Combat Medic" rolls 2d8 + INT instead of
|
||||
// the base 1d8 + INT. The bonus-action treatment described in the
|
||||
// JSON is a HUD-side concern (the resource economy is unchanged);
|
||||
// this hook adjusts only the dice.
|
||||
int rolled;
|
||||
if (c.SubclassId == "body_wright")
|
||||
{
|
||||
rolled = enc.RollDie(8) + enc.RollDie(8);
|
||||
}
|
||||
else
|
||||
{
|
||||
rolled = enc.RollDie(8);
|
||||
}
|
||||
int healed = Math.Max(1, rolled + intMod);
|
||||
// Phase 6.5 M4 — Medical Incompatibility: hybrid recipients heal at
|
||||
// 75% effectiveness (round down, min 1). Non-hybrids pass through.
|
||||
int delivered = healed;
|
||||
if (target.SourceCharacter is { } targetCharacter)
|
||||
delivered = Theriapolis.Core.Rules.Character.HybridDetriments.ScaleHealForHybrid(
|
||||
targetCharacter, healed);
|
||||
target.CurrentHp = Math.Min(target.MaxHp, target.CurrentHp + delivered);
|
||||
c.FieldRepairUsesRemaining--;
|
||||
string scaledNote = (target.SourceCharacter?.IsHybrid ?? false) && delivered != healed
|
||||
? $" (hybrid → {delivered})"
|
||||
: "";
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{healer.Name} Field Repair on {target.Name}: rolled {rolled} + INT {intMod:+#;-#;0} = {healed} HP{scaledNote}. ({target.CurrentHp}/{target.MaxHp})");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Covenant-Keeper <c>lay_on_paws</c>. Action; spend up to a fixed
|
||||
/// amount from a pool of <c>5 × CHA</c> HP per long rest (per-encounter
|
||||
/// at M1) to heal a target. Pool tops up via
|
||||
/// <see cref="EnsureLayOnPawsPoolReady"/>; spending one point cures
|
||||
/// disease — not modelled here yet (no disease subsystem).
|
||||
/// </summary>
|
||||
public static bool TryLayOnPaws(Encounter enc, Combatant healer, Combatant target, int requestHp)
|
||||
{
|
||||
var c = healer.SourceCharacter;
|
||||
if (c is null || c.ClassDef.Id != "covenant_keeper") return false;
|
||||
if (target.IsDown) return false;
|
||||
if (requestHp <= 0) return false;
|
||||
|
||||
int spend = Math.Min(requestHp, c.LayOnPawsPoolRemaining);
|
||||
if (spend <= 0)
|
||||
{
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note, $"{healer.Name}: Lay on Paws pool empty (rest to refill).");
|
||||
return false;
|
||||
}
|
||||
// Phase 6.5 M4 — Medical Incompatibility scales hybrid heal received,
|
||||
// but the *cost* to the pool is the requested amount. (Hybrid pays
|
||||
// the same cost; the inefficiency models the body resisting the
|
||||
// calibration, not the healer wasting effort.)
|
||||
int delivered = spend;
|
||||
if (target.SourceCharacter is { } targetCharacter)
|
||||
delivered = Theriapolis.Core.Rules.Character.HybridDetriments.ScaleHealForHybrid(
|
||||
targetCharacter, spend);
|
||||
target.CurrentHp = Math.Min(target.MaxHp, target.CurrentHp + delivered);
|
||||
c.LayOnPawsPoolRemaining -= spend;
|
||||
string scaledNote = (target.SourceCharacter?.IsHybrid ?? false) && delivered != spend
|
||||
? $" (hybrid → {delivered})"
|
||||
: "";
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{healer.Name} channels Lay on Paws → {target.Name} +{spend} HP{scaledNote}. ({target.CurrentHp}/{target.MaxHp}, pool {c.LayOnPawsPoolRemaining})");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialise / refresh the Lay on Paws pool to <c>5 × CHA mod</c> if
|
||||
/// the character has the <c>lay_on_paws</c> feature. Called at
|
||||
/// encounter start so M1 (no rest model) treats every encounter as
|
||||
/// fully rested. CHA mod ≤ 0 yields a 1-point minimum so a low-CHA
|
||||
/// Covenant-Keeper still has a token pool.
|
||||
/// </summary>
|
||||
public static void EnsureLayOnPawsPoolReady(Theriapolis.Core.Rules.Character.Character c)
|
||||
{
|
||||
if (c.ClassDef.Id != "covenant_keeper") return;
|
||||
int chaMod = c.Abilities.ModFor(AbilityId.CHA);
|
||||
int target = Math.Max(1, 5 * Math.Max(1, chaMod));
|
||||
if (c.LayOnPawsPoolRemaining < target)
|
||||
c.LayOnPawsPoolRemaining = target;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh the Field Repair use to 1 if it's been spent. Encounter-rest
|
||||
/// equivalence per the Phase 5 contract.
|
||||
/// </summary>
|
||||
public static void EnsureFieldRepairReady(Theriapolis.Core.Rules.Character.Character c)
|
||||
{
|
||||
if (c.ClassDef.Id != "claw_wright") return;
|
||||
if (c.FieldRepairUsesRemaining < 1) c.FieldRepairUsesRemaining = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh Vocalization Dice to 4 if any have been spent. Encounter-rest
|
||||
/// equivalence per the Phase 5 contract.
|
||||
/// </summary>
|
||||
public static void EnsureVocalizationDiceReady(Theriapolis.Core.Rules.Character.Character c)
|
||||
{
|
||||
if (c.ClassDef.Id != "muzzle_speaker") return;
|
||||
if (c.VocalizationDiceRemaining < 4) c.VocalizationDiceRemaining = 4;
|
||||
}
|
||||
|
||||
// ── Phase 6.5 M3: Pheromone Craft (Scent-Broker) ─────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Pheromone Craft uses-per-encounter cap based on character level. The
|
||||
/// JSON ladder unlocks more uses at higher levels:
|
||||
/// L1–4 → 0 (feature not unlocked yet),
|
||||
/// L5–8 → 2 (pheromone_craft_2 from L2 entry retains here, but the L5
|
||||
/// entry brings pheromone_craft_3),
|
||||
/// L9–12 → 4, L13+ → 5. The granted-at-each-level structure in
|
||||
/// <c>classes.json</c> uses the highest-tier feature unlocked.
|
||||
/// </summary>
|
||||
public static int PheromoneUsesAtLevel(int level) => level switch
|
||||
{
|
||||
>= 13 => 5,
|
||||
>= 9 => 4,
|
||||
>= 5 => 3,
|
||||
>= 2 => 2,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Refill the Scent-Broker's Pheromone Craft pool to the per-level cap.
|
||||
/// Encounter-rest equivalence; Phase 8 replaces with real short-rest.
|
||||
/// </summary>
|
||||
public static void EnsurePheromoneUsesReady(Theriapolis.Core.Rules.Character.Character c)
|
||||
{
|
||||
if (c.ClassDef.Id != "scent_broker") return;
|
||||
int cap = PheromoneUsesAtLevel(c.Level);
|
||||
if (c.PheromoneUsesRemaining < cap) c.PheromoneUsesRemaining = cap;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scent-Broker <c>pheromone_craft_*</c>. Bonus action; emits a 10-ft
|
||||
/// (= 2 tactical tile) cloud centred on the caster. Every creature in
|
||||
/// range that the caster considers hostile must make a CON save vs.
|
||||
/// <c>DC = 8 + prof + WIS mod</c>; on failure, the pheromone-mapped
|
||||
/// <see cref="Theriapolis.Core.Rules.Stats.Condition"/> is applied
|
||||
/// (<see cref="PheromoneTypeExtensions.AppliedCondition"/>).
|
||||
/// Consumes one Pheromone Use.
|
||||
/// </summary>
|
||||
public static bool TryEmitPheromone(Encounter enc, Combatant caster, PheromoneType type)
|
||||
{
|
||||
var c = caster.SourceCharacter;
|
||||
if (c is null || c.ClassDef.Id != "scent_broker") return false;
|
||||
if (c.Level < 2)
|
||||
{
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{caster.Name}: Pheromone Craft unlocks at level 2.");
|
||||
return false;
|
||||
}
|
||||
if (c.PheromoneUsesRemaining <= 0)
|
||||
{
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{caster.Name}: Pheromone Craft exhausted (rest to recover).");
|
||||
return false;
|
||||
}
|
||||
|
||||
int wisMod = c.Abilities.ModFor(Theriapolis.Core.Rules.Stats.AbilityId.WIS);
|
||||
int dc = 8 + c.ProficiencyBonus + wisMod;
|
||||
var applied = type.AppliedCondition();
|
||||
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{caster.Name} emits {type.DisplayName()} pheromone (DC {dc}).");
|
||||
|
||||
int affected = 0;
|
||||
foreach (var t in enc.Participants)
|
||||
{
|
||||
if (t.Id == caster.Id) continue;
|
||||
if (t.IsDown) continue;
|
||||
// 10 ft. cloud = within 1 empty tile (≤ 1 edge-to-edge).
|
||||
if (ReachAndCover.EdgeToEdgeChebyshev(caster, t) > 1) continue;
|
||||
// Only target hostiles for offensive pheromones; calm targets
|
||||
// hostiles too (charmed-toward-source is the desired effect).
|
||||
bool sameSide = (caster.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|
||||
|| caster.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
|
||||
&& (t.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
|
||||
|| t.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
|
||||
if (sameSide) continue;
|
||||
|
||||
// Roll CON save: 1d20 + CON mod.
|
||||
int conMod = Theriapolis.Core.Rules.Stats.AbilityScores.Mod(t.Abilities.CON);
|
||||
int saveRoll = enc.RollD20();
|
||||
int saveTotal = saveRoll + conMod;
|
||||
bool saved = saveTotal >= dc;
|
||||
enc.AppendLog(CombatLogEntry.Kind.Save,
|
||||
$" {t.Name} CON save: {saveRoll}{conMod:+#;-#;0} = {saveTotal} vs DC {dc} → {(saved ? "saved" : "FAILED")}");
|
||||
if (!saved && applied != Theriapolis.Core.Rules.Stats.Condition.None)
|
||||
{
|
||||
Resolver.ApplyCondition(enc, t, applied);
|
||||
affected++;
|
||||
}
|
||||
}
|
||||
|
||||
c.PheromoneUsesRemaining--;
|
||||
if (affected == 0)
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$" No hostiles affected by the pheromone.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Phase 6.5 M3: Covenant Authority (Covenant-Keeper) ───────────────
|
||||
|
||||
/// <summary>
|
||||
/// Covenant Authority uses-per-encounter cap based on level. JSON
|
||||
/// ladder: <c>covenants_authority_2/3/4/5</c> at L2/L9/L13/L17 →
|
||||
/// 2 / 3 / 4 / 5.
|
||||
/// </summary>
|
||||
public static int CovenantAuthorityUsesAtLevel(int level) => level switch
|
||||
{
|
||||
>= 17 => 5,
|
||||
>= 13 => 4,
|
||||
>= 9 => 3,
|
||||
>= 2 => 2,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Refill the Covenant-Keeper's Authority pool to the per-level cap.
|
||||
/// </summary>
|
||||
public static void EnsureCovenantAuthorityReady(Theriapolis.Core.Rules.Character.Character c)
|
||||
{
|
||||
if (c.ClassDef.Id != "covenant_keeper") return;
|
||||
int cap = CovenantAuthorityUsesAtLevel(c.Level);
|
||||
if (c.CovenantAuthorityUsesRemaining < cap) c.CovenantAuthorityUsesRemaining = cap;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Covenant-Keeper <c>covenants_authority_*</c>. Bonus action; declares
|
||||
/// an oath against a target hostile; for 10 rounds (1 minute), the
|
||||
/// oath-marked creature suffers -2 to attack rolls against the
|
||||
/// Covenant-Keeper. Consumes one use. The full three-option
|
||||
/// description (Compel Truth / Rebuke Predation / Shield the Innocent)
|
||||
/// is plan-deferred to Phase 8/9 dialogue + AoE polish; M3 ships the
|
||||
/// simple combat-marker mechanic per the Phase 6.5 plan §4.4.
|
||||
/// </summary>
|
||||
public static bool TryDeclareOath(Encounter enc, Combatant caster, Combatant target)
|
||||
{
|
||||
var c = caster.SourceCharacter;
|
||||
if (c is null || c.ClassDef.Id != "covenant_keeper") return false;
|
||||
if (c.Level < 2)
|
||||
{
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{caster.Name}: Covenant's Authority unlocks at level 2.");
|
||||
return false;
|
||||
}
|
||||
if (c.CovenantAuthorityUsesRemaining <= 0)
|
||||
{
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{caster.Name}: Covenant's Authority exhausted (rest to recover).");
|
||||
return false;
|
||||
}
|
||||
if (target.IsDown) return false;
|
||||
if (target.Id == caster.Id) return false;
|
||||
|
||||
target.OathMarkRound = enc.RoundNumber;
|
||||
target.OathMarkBy = caster.Id;
|
||||
c.CovenantAuthorityUsesRemaining--;
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{caster.Name} pronounces an oath against {target.Name} — -2 attack vs caster for 1 minute.");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M3 — to-hit penalty applied to a marked attacker rolling
|
||||
/// against the Covenant-Keeper who marked them. Returns 0 when no
|
||||
/// active oath, -2 when the marked attacker targets the marker, and 0
|
||||
/// for any other target (the oath is target-specific).
|
||||
/// </summary>
|
||||
public static int OathAttackPenalty(Encounter enc, Combatant attacker, Combatant defender)
|
||||
{
|
||||
if (attacker.OathMarkRound is not int markRound) return 0;
|
||||
if (attacker.OathMarkBy is not int markBy) return 0;
|
||||
// Expire after 10 rounds.
|
||||
if (enc.RoundNumber > markRound + 9)
|
||||
{
|
||||
attacker.OathMarkRound = null;
|
||||
attacker.OathMarkBy = null;
|
||||
return 0;
|
||||
}
|
||||
if (markBy != defender.Id) return 0; // penalty only when attacking the marker
|
||||
return -2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Muzzle-Speaker Vocalization Dice (level-1 d6, scaling to d8/d10/d12
|
||||
/// at L5/L9/L15). Bonus action; consumes one die. The target combatant
|
||||
/// gains <see cref="Combatant.InspirationDieSides"/> = the current die
|
||||
/// size; the next attack/check/save they make rolls that bonus.
|
||||
/// </summary>
|
||||
public static bool TryGrantVocalizationDie(Encounter enc, Combatant caster, Combatant ally)
|
||||
{
|
||||
var c = caster.SourceCharacter;
|
||||
if (c is null || c.ClassDef.Id != "muzzle_speaker") return false;
|
||||
if (caster.Id == ally.Id) return false; // can't inspire yourself
|
||||
if (c.VocalizationDiceRemaining <= 0)
|
||||
{
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: Vocalization Dice spent (rest to recover).");
|
||||
return false;
|
||||
}
|
||||
if (ally.InspirationDieSides > 0)
|
||||
{
|
||||
// Already inspired — overlapping inspirations don't stack at L1.
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: {ally.Name} already inspired.");
|
||||
return false;
|
||||
}
|
||||
// Range gate: 60 ft. = 12 tactical tiles per the standard 5-ft tile.
|
||||
int dist = caster.DistanceTo(ally);
|
||||
if (dist > 12)
|
||||
{
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: {ally.Name} too far for Vocalization Dice ({dist}/12).");
|
||||
return false;
|
||||
}
|
||||
|
||||
int sides = VocalizationDieSidesFor(c.Level);
|
||||
ally.InspirationDieSides = sides;
|
||||
c.VocalizationDiceRemaining--;
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$"{caster.Name} grants {ally.Name} a Vocalization Die (1d{sides}). ({c.VocalizationDiceRemaining} left)");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard d20 Bardic Inspiration die ladder, mapped to Vocalization
|
||||
/// Dice per <c>classes.json</c> level table:
|
||||
/// 1–4 → d6; 5–8 → d8; 9–14 → d10; 15+ → d12.
|
||||
/// </summary>
|
||||
public static int VocalizationDieSidesFor(int level) => level switch
|
||||
{
|
||||
>= 15 => 12,
|
||||
>= 9 => 10,
|
||||
>= 5 => 8,
|
||||
_ => 6,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Consume an inspiration die (if any) on a d20 roll. Adds 1d<sides>
|
||||
/// to the d20 result and clears the field. Returns the bonus added (0 if
|
||||
/// no inspiration was active).
|
||||
/// </summary>
|
||||
public static int ConsumeInspirationDie(Encounter enc, Combatant roller)
|
||||
{
|
||||
if (roller.InspirationDieSides <= 0) return 0;
|
||||
int sides = roller.InspirationDieSides;
|
||||
int rolled = enc.RollDie(sides);
|
||||
roller.InspirationDieSides = 0;
|
||||
enc.AppendLog(CombatLogEntry.Kind.Note,
|
||||
$" {roller.Name} adds Vocalization Die (1d{sides} = {rolled}).");
|
||||
return rolled;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static bool IsOneHanded(Theriapolis.Core.Rules.Character.Character c)
|
||||
{
|
||||
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
|
||||
if (main is null) return false;
|
||||
if (HasProp(main.Def, "two_handed")) return false;
|
||||
var off = c.Inventory.GetEquipped(EquipSlot.OffHand);
|
||||
// Duelist requires the off hand to be empty (shields don't count as another weapon, but the d20 spec says "no other weapon" — for M6 we treat shields as OK).
|
||||
if (off is null) return true;
|
||||
return string.Equals(off.Def.Kind, "shield", System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsTwoHanded(Theriapolis.Core.Rules.Character.Character c)
|
||||
{
|
||||
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
|
||||
return main is not null && HasProp(main.Def, "two_handed");
|
||||
}
|
||||
|
||||
private static bool IsFinesseOrRanged(Combatant attacker, AttackOption attack)
|
||||
{
|
||||
if (attack.IsRanged) return true;
|
||||
var c = attacker.SourceCharacter;
|
||||
if (c is null) return false;
|
||||
var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
|
||||
if (main is null) return false;
|
||||
return HasProp(main.Def, "finesse");
|
||||
}
|
||||
|
||||
private static bool HasProp(Theriapolis.Core.Data.ItemDef def, string prop)
|
||||
{
|
||||
foreach (var p in def.Properties)
|
||||
if (string.Equals(p, prop, System.StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Tactical-tile line-of-sight via Bresenham. The caller supplies a
|
||||
/// "blocked at (x, y)?" predicate so this helper stays free of a hard
|
||||
/// dependency on TacticalChunk / WorldState — Phase 5 M4 tests use a flat
|
||||
/// arena (always-clear); M5 plugs in the live tactical-tile sampler.
|
||||
/// </summary>
|
||||
public static class LineOfSight
|
||||
{
|
||||
/// <summary>
|
||||
/// True if a straight line from <paramref name="from"/> to
|
||||
/// <paramref name="to"/> traverses only un-blocked tiles. Endpoints
|
||||
/// themselves are NOT consulted — only the intermediate tiles.
|
||||
/// </summary>
|
||||
public static bool HasLine(Vec2 from, Vec2 to, System.Func<int, int, bool> isBlockedAt)
|
||||
{
|
||||
int x0 = (int)System.Math.Floor(from.X);
|
||||
int y0 = (int)System.Math.Floor(from.Y);
|
||||
int x1 = (int)System.Math.Floor(to.X);
|
||||
int y1 = (int)System.Math.Floor(to.Y);
|
||||
|
||||
int dx = System.Math.Abs(x1 - x0);
|
||||
int dy = System.Math.Abs(y1 - y0);
|
||||
int sx = x0 < x1 ? 1 : -1;
|
||||
int sy = y0 < y1 ? 1 : -1;
|
||||
int err = dx - dy;
|
||||
|
||||
int x = x0, y = y0;
|
||||
while (true)
|
||||
{
|
||||
// Skip the endpoint itself
|
||||
if (!(x == x0 && y == y0) && !(x == x1 && y == y1))
|
||||
{
|
||||
if (isBlockedAt(x, y)) return false;
|
||||
}
|
||||
if (x == x1 && y == y1) return true;
|
||||
int e2 = 2 * err;
|
||||
if (e2 > -dy) { err -= dy; x += sx; }
|
||||
if (e2 < dx) { err += dx; y += sy; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Convenience: always-clear arena. Used by combat-duel and most M4 tests.</summary>
|
||||
public static readonly System.Func<int, int, bool> AlwaysClear = (_, _) => false;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Tactical;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="TacticalSpawn"/> + chunk's <see cref="TacticalChunk.DangerZone"/>
|
||||
/// to the actual <see cref="NpcTemplateDef"/> that should spawn there.
|
||||
/// Lookup table lives in <c>npc_templates.json</c>'s
|
||||
/// <c>spawn_kind_to_template_by_zone</c> map (loaded into
|
||||
/// <see cref="NpcTemplateContent.SpawnKindToTemplateByZone"/>).
|
||||
///
|
||||
/// Returns null when no template is configured for the spawn kind/zone (the
|
||||
/// caller should skip that spawn — chunk is silently denser, that's OK).
|
||||
/// </summary>
|
||||
public static class NpcInstantiator
|
||||
{
|
||||
public static NpcTemplateDef? PickTemplate(
|
||||
SpawnKind kind,
|
||||
int dangerZone,
|
||||
NpcTemplateContent content)
|
||||
{
|
||||
if (kind == SpawnKind.None) return null;
|
||||
string kindKey = kind.ToString();
|
||||
if (!content.SpawnKindToTemplateByZone.TryGetValue(kindKey, out var byZone))
|
||||
return null;
|
||||
if (byZone.Length == 0) return null;
|
||||
// Clamp the zone index to the table's length.
|
||||
int zoneIdx = System.Math.Clamp(dangerZone, 0, byZone.Length - 1);
|
||||
string templateId = byZone[zoneIdx];
|
||||
foreach (var t in content.Templates)
|
||||
if (string.Equals(t.Id, templateId, System.StringComparison.OrdinalIgnoreCase))
|
||||
return t;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M3 — pheromone compounds a Scent-Broker can deploy via
|
||||
/// Pheromone Craft. Each maps to a <see cref="Theriapolis.Core.Rules.Stats.Condition"/>
|
||||
/// applied to creatures in the radius that fail their CON save.
|
||||
///
|
||||
/// The four compounds match <c>theriapolis-rpg-equipment.md</c>'s pheromone
|
||||
/// vials, but here they're emitted directly via the class feature without
|
||||
/// the consumable.
|
||||
/// </summary>
|
||||
public enum PheromoneType : byte
|
||||
{
|
||||
/// <summary>Failed save → <see cref="Theriapolis.Core.Rules.Stats.Condition.Frightened"/>.</summary>
|
||||
Fear = 0,
|
||||
/// <summary>Failed save → <see cref="Theriapolis.Core.Rules.Stats.Condition.Charmed"/> (won't attack source).</summary>
|
||||
Calm = 1,
|
||||
/// <summary>Failed save → <see cref="Theriapolis.Core.Rules.Stats.Condition.Dazed"/> (loss of focus).</summary>
|
||||
Arousal = 2,
|
||||
/// <summary>Failed save → <see cref="Theriapolis.Core.Rules.Stats.Condition.Poisoned"/> (debuff).</summary>
|
||||
Nausea = 3,
|
||||
}
|
||||
|
||||
public static class PheromoneTypeExtensions
|
||||
{
|
||||
/// <summary>Maps a <see cref="PheromoneType"/> to the condition it applies on a failed save.</summary>
|
||||
public static Theriapolis.Core.Rules.Stats.Condition AppliedCondition(this PheromoneType t) => t switch
|
||||
{
|
||||
PheromoneType.Fear => Theriapolis.Core.Rules.Stats.Condition.Frightened,
|
||||
PheromoneType.Calm => Theriapolis.Core.Rules.Stats.Condition.Charmed,
|
||||
PheromoneType.Arousal => Theriapolis.Core.Rules.Stats.Condition.Dazed,
|
||||
PheromoneType.Nausea => Theriapolis.Core.Rules.Stats.Condition.Poisoned,
|
||||
_ => Theriapolis.Core.Rules.Stats.Condition.None,
|
||||
};
|
||||
|
||||
/// <summary>Human-readable display name for combat log entries.</summary>
|
||||
public static string DisplayName(this PheromoneType t) => t switch
|
||||
{
|
||||
PheromoneType.Fear => "Fear",
|
||||
PheromoneType.Calm => "Calm",
|
||||
PheromoneType.Arousal => "Arousal",
|
||||
PheromoneType.Nausea => "Nausea",
|
||||
_ => "Unknown",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Size-aware spatial helpers for combat. Combatants occupy
|
||||
/// <see cref="Stats.SizeExtensions.FootprintTiles"/>² tactical tiles
|
||||
/// anchored at their integer <see cref="Combatant.Position"/>; this helper
|
||||
/// computes edge-to-edge Chebyshev distance and reach predicates.
|
||||
/// </summary>
|
||||
public static class ReachAndCover
|
||||
{
|
||||
/// <summary>
|
||||
/// Edge-to-edge Chebyshev distance — number of empty tiles between two
|
||||
/// footprints. Adjacent (sharing an edge or corner) returns 0; one
|
||||
/// empty tile between returns 1; overlapping returns 0.
|
||||
/// </summary>
|
||||
public static int EdgeToEdgeChebyshev(Combatant a, Combatant b)
|
||||
{
|
||||
int aSize = a.Size.FootprintTiles();
|
||||
int bSize = b.Size.FootprintTiles();
|
||||
int aMinX = (int)System.Math.Floor(a.Position.X);
|
||||
int aMinY = (int)System.Math.Floor(a.Position.Y);
|
||||
int aMaxX = aMinX + aSize - 1;
|
||||
int aMaxY = aMinY + aSize - 1;
|
||||
int bMinX = (int)System.Math.Floor(b.Position.X);
|
||||
int bMinY = (int)System.Math.Floor(b.Position.Y);
|
||||
int bMaxX = bMinX + bSize - 1;
|
||||
int bMaxY = bMinY + bSize - 1;
|
||||
|
||||
// Per-axis gap: positive = number of tile-steps to bring edges to
|
||||
// touching (then -1 because touching = 0 empty tiles between).
|
||||
int dx = System.Math.Max(0, System.Math.Max(aMinX - bMaxX, bMinX - aMaxX) - 1);
|
||||
int dy = System.Math.Max(0, System.Math.Max(aMinY - bMaxY, bMinY - aMaxY) - 1);
|
||||
return System.Math.Max(dx, dy);
|
||||
}
|
||||
|
||||
/// <summary>True if <paramref name="defender"/> is within the attack's reach (melee) or short range (ranged).</summary>
|
||||
public static bool IsInReach(Combatant attacker, Combatant defender, AttackOption attack)
|
||||
{
|
||||
int dist = EdgeToEdgeChebyshev(attacker, defender);
|
||||
if (attack.IsRanged)
|
||||
return dist <= attack.RangeLongTiles;
|
||||
return dist <= attack.ReachTiles;
|
||||
}
|
||||
|
||||
/// <summary>True if the defender sits past short range (disadvantage on the attack).</summary>
|
||||
public static bool IsLongRange(Combatant attacker, Combatant defender, AttackOption attack)
|
||||
{
|
||||
if (!attack.IsRanged) return false;
|
||||
int dist = EdgeToEdgeChebyshev(attacker, defender);
|
||||
return dist > attack.RangeShortTiles && dist <= attack.RangeLongTiles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One step of greedy movement toward <paramref name="goal"/>. Returns
|
||||
/// the new position one tile closer in 8-connected (Chebyshev) space.
|
||||
/// Movement budget is ignored — the caller is responsible for charging it.
|
||||
/// </summary>
|
||||
public static Vec2 StepToward(Vec2 from, Vec2 goal)
|
||||
{
|
||||
int dx = System.Math.Sign(goal.X - from.X);
|
||||
int dy = System.Math.Sign(goal.Y - from.Y);
|
||||
return new Vec2((int)from.X + dx, (int)from.Y + dy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Tactical;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Settlements;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 — turns a chunk's <see cref="SpawnKind.Resident"/> records
|
||||
/// into live <see cref="NpcActor"/>s.
|
||||
///
|
||||
/// Each <see cref="TacticalSpawn"/> with kind Resident sits at a
|
||||
/// world-pixel position that <see cref="SettlementStamper"/> emitted from a
|
||||
/// <see cref="BuildingResidentSlot"/>. Resolution:
|
||||
///
|
||||
/// 1. Walk the world's settlements. Find the one whose
|
||||
/// <see cref="Settlement.Buildings"/> contains a building footprint
|
||||
/// that contains this spawn point. Within that building, find the
|
||||
/// slot whose <c>SpawnX/SpawnY</c> match — that's the role tag.
|
||||
/// 2. Look up the resident template. Named (anchor-prefixed) tags hit
|
||||
/// <see cref="ContentResolver.ResidentsByRoleTag"/> directly. Generic
|
||||
/// tags hit <see cref="ContentResolver.Residents"/> filtered by
|
||||
/// <see cref="ResidentTemplateDef.RoleTag"/> equality, weighted by
|
||||
/// <see cref="ResidentTemplateDef.Weight"/>.
|
||||
/// 3. Build an <see cref="NpcActor"/> from the chosen template. Register
|
||||
/// named-role NPCs in the <see cref="AnchorRegistry"/> so quest
|
||||
/// scripts can resolve them by symbolic id.
|
||||
///
|
||||
/// The lookup is a linear walk over settlements (small N — < 100) but is
|
||||
/// deterministic for a given (worldSeed, chunk, spawnIndex).
|
||||
/// </summary>
|
||||
public static class ResidentInstantiator
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve and spawn an NpcActor for a single Resident spawn record.
|
||||
/// Returns null when the world has no resident template configured for
|
||||
/// this slot's role tag (the spawn is silently dropped — the building
|
||||
/// just stays empty, which is fine).
|
||||
/// </summary>
|
||||
public static NpcActor? Spawn(
|
||||
ulong worldSeed,
|
||||
TacticalChunk chunk,
|
||||
int spawnIndex,
|
||||
TacticalSpawn spawn,
|
||||
WorldState world,
|
||||
ContentResolver content,
|
||||
ActorManager actors,
|
||||
AnchorRegistry? registry = null)
|
||||
{
|
||||
if (spawn.Kind != SpawnKind.Resident) return null;
|
||||
|
||||
int worldPxX = chunk.OriginX + spawn.LocalX;
|
||||
int worldPxY = chunk.OriginY + spawn.LocalY;
|
||||
|
||||
if (!TryFindSlot(world, worldPxX, worldPxY, out var settlement, out var building, out var slot))
|
||||
return null;
|
||||
|
||||
var template = ResolveTemplate(slot.RoleTag, content, worldSeed, settlement!.Id, building!.Id, spawnIndex);
|
||||
if (template is null) return null;
|
||||
|
||||
var npc = new NpcActor(template)
|
||||
{
|
||||
Id = -1, // ActorManager assigns
|
||||
Position = new Vec2(worldPxX, worldPxY),
|
||||
SourceChunk = chunk.Coord,
|
||||
SourceSpawnIndex = spawnIndex,
|
||||
// The named role tag wins over the generic one declared on the
|
||||
// template — preserves "millhaven.innkeeper" identity even when
|
||||
// the generic "innkeeper" template is what spawned.
|
||||
RoleTag = string.IsNullOrEmpty(slot.RoleTag) ? template.RoleTag : slot.RoleTag,
|
||||
// Phase 6 M5 — anchor the resident to its host settlement so
|
||||
// RepPropagation can compute their local faction standing.
|
||||
HomeSettlementId = settlement.Id,
|
||||
};
|
||||
|
||||
var spawned = actors.SpawnNpc(npc);
|
||||
if (registry is not null)
|
||||
{
|
||||
if (settlement.Anchor is not null)
|
||||
registry.RegisterAnchor(settlement.Anchor.Value, settlement.Id);
|
||||
registry.RegisterRole(spawned.RoleTag, spawned.Id);
|
||||
}
|
||||
return spawned;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick the resident template for a given role tag. Named anchor-
|
||||
/// prefixed tags ("millhaven.innkeeper") prefer named templates;
|
||||
/// generic tags ("innkeeper") roll among matching generics by weight.
|
||||
/// </summary>
|
||||
public static ResidentTemplateDef? ResolveTemplate(
|
||||
string roleTag,
|
||||
ContentResolver content,
|
||||
ulong worldSeed,
|
||||
int settlementId,
|
||||
int buildingId,
|
||||
int spawnIndex)
|
||||
{
|
||||
// Named, anchor-prefixed: prefer the exact match.
|
||||
if (content.ResidentsByRoleTag.TryGetValue(roleTag, out var named))
|
||||
return named;
|
||||
|
||||
// Generic: collect all unnamed templates whose RoleTag equals the
|
||||
// suffix-stripped tag (e.g. "millhaven.innkeeper" → "innkeeper").
|
||||
string suffix = roleTag;
|
||||
int dot = roleTag.LastIndexOf('.');
|
||||
if (dot >= 0) suffix = roleTag[(dot + 1)..];
|
||||
|
||||
var pool = new List<ResidentTemplateDef>();
|
||||
foreach (var r in content.Residents.Values)
|
||||
if (!r.Named && string.Equals(r.RoleTag, suffix, System.StringComparison.OrdinalIgnoreCase))
|
||||
pool.Add(r);
|
||||
if (pool.Count == 0) return null;
|
||||
if (pool.Count == 1) return pool[0];
|
||||
|
||||
// Weighted roll, deterministic per (worldSeed, settlementId, buildingId, spawnIndex).
|
||||
var rng = SeededRng.ForSubsystem(worldSeed,
|
||||
unchecked(C.RNG_NPC_SPAWN ^ (ulong)settlementId
|
||||
^ ((ulong)buildingId << 16)
|
||||
^ ((ulong)spawnIndex << 32)));
|
||||
// Sort for stable iteration before the RNG roll.
|
||||
pool.Sort(static (a, b) => string.Compare(a.Id, b.Id, System.StringComparison.Ordinal));
|
||||
float total = 0f;
|
||||
foreach (var t in pool) total += System.Math.Max(0f, t.Weight);
|
||||
if (total <= 0f) return pool[0];
|
||||
float roll = rng.NextFloat() * total;
|
||||
float acc = 0f;
|
||||
foreach (var t in pool)
|
||||
{
|
||||
acc += System.Math.Max(0f, t.Weight);
|
||||
if (roll <= acc) return t;
|
||||
}
|
||||
return pool[^1];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk the world's settlements to find the one whose building footprint
|
||||
/// contains <paramref name="worldPxX"/>/<paramref name="worldPxY"/> AND
|
||||
/// whose resident slot sits exactly on that point.
|
||||
/// </summary>
|
||||
public static bool TryFindSlot(
|
||||
WorldState world, int worldPxX, int worldPxY,
|
||||
out Settlement? settlement, out BuildingFootprint? building, out BuildingResidentSlot slot)
|
||||
{
|
||||
settlement = null;
|
||||
building = null;
|
||||
slot = default;
|
||||
|
||||
foreach (var s in world.Settlements)
|
||||
{
|
||||
if (!s.BuildingsResolved) continue;
|
||||
foreach (var b in s.Buildings)
|
||||
{
|
||||
if (!b.ContainsTile(worldPxX, worldPxY)) continue;
|
||||
foreach (var r in b.Residents)
|
||||
{
|
||||
if (r.SpawnX == worldPxX && r.SpawnY == worldPxY)
|
||||
{
|
||||
settlement = s;
|
||||
building = b;
|
||||
slot = r;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Pure d20-adjacent rule evaluator. Static — no per-encounter state lives
|
||||
/// here; everything flows through <see cref="Encounter"/> (dice + log) and
|
||||
/// the supplied <see cref="Combatant"/> instances (HP + conditions).
|
||||
///
|
||||
/// Phase 5 M4 ships AttemptAttack, MakeSave, ApplyDamage, ApplyCondition.
|
||||
/// Class-feature combat effects (Sneak Attack damage, Rage damage bonus,
|
||||
/// fighting-style modifiers, etc.) are layered on at M6 by inspecting the
|
||||
/// attacker's <see cref="Character"/> features in <see cref="AttemptAttack"/>.
|
||||
/// </summary>
|
||||
public static class Resolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Roll an attack from <paramref name="attacker"/> against
|
||||
/// <paramref name="target"/>. Logs the outcome on
|
||||
/// <paramref name="enc"/>'s log. Mutates target HP if the attack hits.
|
||||
/// </summary>
|
||||
public static AttackResult AttemptAttack(
|
||||
Encounter enc,
|
||||
Combatant attacker,
|
||||
Combatant target,
|
||||
AttackOption attack,
|
||||
SituationFlags situation = SituationFlags.None)
|
||||
{
|
||||
// Range/long-range disadvantage decoration: if the attack is ranged
|
||||
// and the target is past short range, OR the calling code is firing
|
||||
// a ranged attack into melee, fold those in.
|
||||
if (attack.IsRanged && ReachAndCover.IsLongRange(attacker, target, attack))
|
||||
situation |= SituationFlags.LongRange;
|
||||
|
||||
// Phase 6.5 M2 — Pack-Forged "Packmate's Howl" consumption: if the
|
||||
// target is howl-marked by an ally of this attacker, force advantage
|
||||
// on this attack roll.
|
||||
if (FeatureProcessor.ConsumeHowlAdvantage(enc, attacker, target))
|
||||
situation |= SituationFlags.Advantage;
|
||||
|
||||
// Phase 6.5 M3 — Frightened attackers roll at disadvantage.
|
||||
if (attacker.Conditions.Contains(Condition.Frightened))
|
||||
situation |= SituationFlags.Disadvantage;
|
||||
|
||||
var (kept, other) = enc.RollD20WithMode(situation);
|
||||
// Phase 5 M6: stack Sentinel Stance and other per-combatant AC bonuses.
|
||||
// Phase 6.5 M2 — pass the encounter so passive subclass AC features
|
||||
// (Herd-Wall Interlock Shields, Lone Fang Isolation Bonus) can read
|
||||
// positional state.
|
||||
int totalAc = target.ArmorClass + situation.CoverAcBonus()
|
||||
+ FeatureProcessor.ApplyAcBonus(target, enc);
|
||||
// Phase 6.5 M1: consume an inspiration die (Vocalization Dice) on
|
||||
// attack rolls. The bonus applies to the d20 total *before* compare;
|
||||
// crits/fumbles still trigger off the natural d20.
|
||||
int inspirationBonus = FeatureProcessor.ConsumeInspirationDie(enc, attacker);
|
||||
// Phase 6.5 M2 — subclass to-hit bonuses (Lone Fang Isolation Bonus).
|
||||
int subclassToHit = FeatureProcessor.ApplyToHitBonus(attacker, enc);
|
||||
// Phase 6.5 M3 — Covenant's Authority oath mark: -2 attack vs. marker.
|
||||
int oathPenalty = FeatureProcessor.OathAttackPenalty(enc, attacker, target);
|
||||
int attackTotal = kept + attack.ToHitBonus + inspirationBonus + subclassToHit + oathPenalty;
|
||||
|
||||
bool natural1 = kept == 1;
|
||||
bool natural20 = kept >= attack.CritOnNatural;
|
||||
bool isCrit = natural20;
|
||||
bool hit = !natural1 && (natural20 || attackTotal >= totalAc);
|
||||
|
||||
int damage = 0;
|
||||
if (hit)
|
||||
{
|
||||
// Damage roll wraps the per-die source so Great Weapon style
|
||||
// can re-roll 1s/2s on damage dice without changing the resolver
|
||||
// contract. The per-die delegate consumes RNG via the encounter.
|
||||
damage = attack.Damage.Roll(
|
||||
sides => FeatureProcessor.GreatWeaponReroll(enc, attacker, attack, enc.RollDie(sides), sides),
|
||||
isCrit);
|
||||
// Per-feature damage bonuses (Duelist, Rage, Sneak Attack).
|
||||
damage += FeatureProcessor.ApplyDamageBonus(enc, attacker, target, attack, isCrit);
|
||||
// Resistance halves damage (Rage vs phys).
|
||||
if (FeatureProcessor.IsResisted(target, attack.Damage.DamageType))
|
||||
damage = damage / 2;
|
||||
ApplyDamage(target, damage);
|
||||
|
||||
// Phase 6.5 M2 — subclass on-hit triggers.
|
||||
// Pack-Forged: melee hit marks the target so allies' next attack
|
||||
// gets advantage.
|
||||
if (!attack.IsRanged)
|
||||
FeatureProcessor.OnPackForgedHit(enc, attacker, target, attack);
|
||||
// Blood Memory: melee kill while raging triggers Predatory Surge.
|
||||
if (target.IsDown && !attack.IsRanged && attacker.RageActive)
|
||||
FeatureProcessor.OnBloodMemoryKill(enc, attacker, attack);
|
||||
|
||||
// Phase 7 M0 — Antler-Guard Retaliatory Strike. Returns 1d8+CON
|
||||
// to the attacker when the target is an Antler-Guard in Sentinel
|
||||
// Stance hit by a melee attack. Calls ApplyDamage on the attacker
|
||||
// directly; the encounter log carries the structured note.
|
||||
if (!attack.IsRanged)
|
||||
FeatureProcessor.OnAntlerGuardHit(enc, attacker, target, attack);
|
||||
}
|
||||
|
||||
var result = new AttackResult
|
||||
{
|
||||
AttackerId = attacker.Id,
|
||||
TargetId = target.Id,
|
||||
AttackName = attack.Name,
|
||||
D20Roll = kept,
|
||||
D20Other = other == -1 ? null : other,
|
||||
ToHitBonus = attack.ToHitBonus,
|
||||
AttackTotal = attackTotal,
|
||||
TargetAc = totalAc,
|
||||
Hit = hit,
|
||||
Crit = isCrit && hit,
|
||||
DamageRolled = damage,
|
||||
TargetHpAfter = target.CurrentHp,
|
||||
Situation = situation,
|
||||
};
|
||||
|
||||
enc.AppendLog(CombatLogEntry.Kind.Attack, result.FormatLog(attacker.Name, target.Name));
|
||||
|
||||
if (target.IsDown)
|
||||
enc.AppendLog(CombatLogEntry.Kind.Death, $"{target.Name} falls.");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Roll a saving throw for <paramref name="target"/> against
|
||||
/// <paramref name="dc"/>. Bonus = ability mod + (proficient ? prof : 0).
|
||||
/// </summary>
|
||||
public static SaveResult MakeSave(
|
||||
Encounter enc,
|
||||
Combatant target,
|
||||
SaveId save,
|
||||
int dc,
|
||||
bool isProficient = false,
|
||||
SituationFlags situation = SituationFlags.None)
|
||||
{
|
||||
var (kept, _) = enc.RollD20WithMode(situation);
|
||||
int bonus = AbilityScores.Mod(target.Abilities.Get(save.Ability()))
|
||||
+ (isProficient ? target.ProficiencyBonus : 0);
|
||||
int total = kept + bonus;
|
||||
bool succ = total >= dc;
|
||||
|
||||
var result = new SaveResult
|
||||
{
|
||||
TargetId = target.Id,
|
||||
Save = save,
|
||||
D20Roll = kept,
|
||||
SaveBonus = bonus,
|
||||
SaveTotal = total,
|
||||
Dc = dc,
|
||||
Succeeded = succ,
|
||||
};
|
||||
enc.AppendLog(CombatLogEntry.Kind.Save,
|
||||
$"{target.Name} {save} save: {total} vs DC {dc} → {(succ ? "succeeds" : "fails")}");
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subtract <paramref name="damage"/> from <paramref name="target"/>'s
|
||||
/// HP, clamped to 0. Does not log (callers like AttemptAttack handle
|
||||
/// the structured log entry; this is the raw mutation).
|
||||
///
|
||||
/// Phase 5 M6: when a player character drops to 0, install a
|
||||
/// <see cref="DeathSaveTracker"/> on the combatant; combat HUD reads
|
||||
/// this and rolls a save at the start of the player's turn until the
|
||||
/// loop resolves (stabilised, revived, or dead).
|
||||
/// </summary>
|
||||
public static void ApplyDamage(Combatant target, int damage)
|
||||
{
|
||||
if (damage <= 0) return;
|
||||
target.CurrentHp = System.Math.Max(0, target.CurrentHp - damage);
|
||||
if (target.CurrentHp == 0 && target.SourceCharacter is not null)
|
||||
{
|
||||
target.Conditions.Add(Condition.Unconscious);
|
||||
target.DeathSaves ??= new DeathSaveTracker();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Heal up to MaxHp. Negative amounts are no-ops (use ApplyDamage).</summary>
|
||||
public static void Heal(Combatant target, int amount)
|
||||
{
|
||||
if (amount <= 0) return;
|
||||
target.CurrentHp = System.Math.Min(target.MaxHp, target.CurrentHp + amount);
|
||||
// If the heal lifts a downed character above 0, the unconscious
|
||||
// condition lifts automatically and the death-save loop resets.
|
||||
if (target.CurrentHp > 0)
|
||||
{
|
||||
target.Conditions.Remove(Condition.Unconscious);
|
||||
target.DeathSaves?.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Apply a condition to a target. Logs the change.</summary>
|
||||
public static void ApplyCondition(Encounter enc, Combatant target, Condition condition)
|
||||
{
|
||||
if (target.Conditions.Add(condition))
|
||||
enc.AppendLog(CombatLogEntry.Kind.ConditionApplied,
|
||||
$"{target.Name} is now {condition}.");
|
||||
}
|
||||
|
||||
/// <summary>Remove a condition from a target. Logs if it was present.</summary>
|
||||
public static void RemoveCondition(Encounter enc, Combatant target, Condition condition)
|
||||
{
|
||||
if (target.Conditions.Remove(condition))
|
||||
enc.AppendLog(CombatLogEntry.Kind.ConditionEnded,
|
||||
$"{target.Name} is no longer {condition}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
public sealed record SaveResult
|
||||
{
|
||||
public required int TargetId { get; init; }
|
||||
public required SaveId Save { get; init; }
|
||||
public required int D20Roll { get; init; }
|
||||
public required int SaveBonus { get; init; }
|
||||
public required int SaveTotal { get; init; } // D20Roll + bonus
|
||||
public required int Dc { get; init; }
|
||||
public required bool Succeeded { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Per-attack situation modifiers. Flags compose: a Sneak Attack with
|
||||
/// Advantage and Disadvantage (e.g. attacker prone, target shadowed)
|
||||
/// cancels to a normal roll per d20 rules.
|
||||
///
|
||||
/// Phase 5 M4 wires the basic six (Advantage/Disadvantage and the four
|
||||
/// resolver-time tags); class-feature flags like Reckless Attack come in
|
||||
/// M6 once the feature engine reads from this enum.
|
||||
/// </summary>
|
||||
[System.Flags]
|
||||
public enum SituationFlags : uint
|
||||
{
|
||||
None = 0,
|
||||
Advantage = 1u << 0,
|
||||
Disadvantage = 1u << 1,
|
||||
/// <summary>Attacker is at long range — disadvantage on the roll per d20.</summary>
|
||||
LongRange = 1u << 2,
|
||||
/// <summary>Attacker has reach + a melee weapon vs. a target that has cover.</summary>
|
||||
HalfCover = 1u << 3,
|
||||
ThreeQuartersCover= 1u << 4,
|
||||
/// <summary>Attacker meets the Sneak Attack precondition (advantage or ally adjacent).</summary>
|
||||
SneakAttackEligible = 1u << 5,
|
||||
/// <summary>Attacker is firing a ranged weapon at a target within 5 ft. — disadvantage.</summary>
|
||||
RangedInMelee = 1u << 6,
|
||||
}
|
||||
|
||||
public static class SituationFlagsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// True when the situation should roll the d20 with advantage. Per
|
||||
/// d20 rules, advantage and disadvantage cancel exactly (no doubling).
|
||||
/// </summary>
|
||||
public static bool RollsAdvantage(this SituationFlags f)
|
||||
{
|
||||
bool adv = (f & SituationFlags.Advantage) != 0;
|
||||
bool dis = (f & SituationFlags.Disadvantage) != 0
|
||||
|| (f & SituationFlags.LongRange) != 0
|
||||
|| (f & SituationFlags.RangedInMelee) != 0;
|
||||
return adv && !dis;
|
||||
}
|
||||
|
||||
/// <summary>True when the situation rolls with disadvantage (and no compensating advantage).</summary>
|
||||
public static bool RollsDisadvantage(this SituationFlags f)
|
||||
{
|
||||
bool adv = (f & SituationFlags.Advantage) != 0;
|
||||
bool dis = (f & SituationFlags.Disadvantage) != 0
|
||||
|| (f & SituationFlags.LongRange) != 0
|
||||
|| (f & SituationFlags.RangedInMelee) != 0;
|
||||
return dis && !adv;
|
||||
}
|
||||
|
||||
/// <summary>Cover modifier applied to AC: 0 / 2 / 5.</summary>
|
||||
public static int CoverAcBonus(this SituationFlags f)
|
||||
{
|
||||
if ((f & SituationFlags.ThreeQuartersCover) != 0) return 5;
|
||||
if ((f & SituationFlags.HalfCover) != 0) return 2;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Theriapolis.Core.Rules.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Mutable per-turn state for the active combatant: action / bonus action /
|
||||
/// reaction availability and remaining movement budget. The
|
||||
/// <see cref="Encounter"/> rebuilds this when each new turn begins; the
|
||||
/// <see cref="Resolver"/> consumes resources as the combatant uses them.
|
||||
///
|
||||
/// Phase 5 M4 tracks the booleans but doesn't enforce them inside Resolver
|
||||
/// (callers can attack twice in a turn if they want — useful for tests).
|
||||
/// M5 introduces per-action-cost gating in the live PlayScreen wrapper.
|
||||
/// </summary>
|
||||
public struct Turn
|
||||
{
|
||||
public int CombatantId;
|
||||
public bool ActionAvailable;
|
||||
public bool BonusActionAvailable;
|
||||
public bool ReactionAvailable;
|
||||
public int RemainingMovementFt;
|
||||
|
||||
public static Turn FreshFor(int combatantId, int speedFt) => new()
|
||||
{
|
||||
CombatantId = combatantId,
|
||||
ActionAvailable = true,
|
||||
BonusActionAvailable = true,
|
||||
ReactionAvailable = true,
|
||||
RemainingMovementFt = speedFt,
|
||||
};
|
||||
|
||||
public void ConsumeAction() => ActionAvailable = false;
|
||||
public void ConsumeBonusAction() => BonusActionAvailable = false;
|
||||
public void ConsumeReaction() => ReactionAvailable = false;
|
||||
public void ConsumeMovement(int feet)
|
||||
{
|
||||
RemainingMovementFt -= feet;
|
||||
if (RemainingMovementFt < 0) RemainingMovementFt = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Rules.Reputation;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Dialogue;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M3 — read/write window into player + npc state used by
|
||||
/// <see cref="DialogueRunner"/> to evaluate conditions and apply effects.
|
||||
/// Keeps the runner free of direct PlayScreen / ActorManager references
|
||||
/// so the runner can be unit-tested with a synthetic context.
|
||||
/// </summary>
|
||||
public sealed class DialogueContext
|
||||
{
|
||||
public NpcActor Npc { get; }
|
||||
public Rules.Character.Character Pc { get; }
|
||||
public PlayerReputation Reputation { get; }
|
||||
public Dictionary<string, int> Flags { get; }
|
||||
public ContentResolver Content { get; }
|
||||
|
||||
/// <summary>Player position for tagging RepEvent origins. Optional; defaults to (0, 0) in tests.</summary>
|
||||
public int PlayerWorldTileX { get; set; }
|
||||
public int PlayerWorldTileY { get; set; }
|
||||
public long WorldClockSeconds { get; set; }
|
||||
|
||||
/// <summary>Set true by <see cref="DialogueRunner"/> when an option fires the open_shop effect.</summary>
|
||||
public bool ShopRequested { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M3 — quest hook stub. set_flag-only for M3; the real quest
|
||||
/// engine wires in M4 and consumes <see cref="StartQuestRequests"/>.
|
||||
/// </summary>
|
||||
public List<string> StartQuestRequests { get; } = new();
|
||||
|
||||
public DialogueContext(NpcActor npc, Rules.Character.Character pc,
|
||||
PlayerReputation rep, Dictionary<string, int> flags,
|
||||
ContentResolver content)
|
||||
{
|
||||
Npc = npc;
|
||||
Pc = pc;
|
||||
Reputation = rep;
|
||||
Flags = flags;
|
||||
Content = content;
|
||||
}
|
||||
|
||||
/// <summary>Effective disposition for the current NPC vs the player. Cached per dialogue turn — recomputed on demand.</summary>
|
||||
public int EffectiveDispositionScore()
|
||||
=> EffectiveDisposition.For(Npc, Pc, Reputation, Content);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Rules.Reputation;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Dialogue;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M3 — walks a <see cref="DialogueDef"/> graph, evaluates option
|
||||
/// conditions, branches skill checks against a deterministic dice
|
||||
/// stream, and applies effects.
|
||||
///
|
||||
/// Determinism:
|
||||
/// <c>dialogueSeed = worldSeed ^ C.RNG_DIALOGUE ^ npcId ^ turnIndex</c>
|
||||
/// Each skill-check option pulls a fresh d20 keyed by
|
||||
/// <c>(npcId, turnIndex, optionIndex)</c> — the cache means re-rendering
|
||||
/// the same node (e.g. tooltip refresh) doesn't re-roll.
|
||||
///
|
||||
/// The runner does not own UI. It exposes <see cref="CurrentNode"/> and
|
||||
/// <see cref="VisibleOptions"/> for the screen to render, plus <see
|
||||
/// cref="History"/> for scrollback. The screen calls <see cref="ChooseOption"/>
|
||||
/// when the player picks one; the runner returns a result describing
|
||||
/// what happened (text to append, skill-check rolled, dialogue ended,
|
||||
/// shop requested).
|
||||
/// </summary>
|
||||
public sealed class DialogueRunner
|
||||
{
|
||||
private readonly DialogueDef _tree;
|
||||
private readonly DialogueContext _ctx;
|
||||
private readonly ulong _worldSeed;
|
||||
private readonly ulong _npcId;
|
||||
private readonly Dictionary<string, DialogueNodeDef> _nodesById;
|
||||
|
||||
/// <summary>Cache of (turnIndex, optionIndex) → (rolled, total) so re-renders don't re-roll.</summary>
|
||||
private readonly Dictionary<(int turn, int option), SkillCheckRoll> _rollCache = new();
|
||||
|
||||
public int TurnIndex { get; private set; }
|
||||
public DialogueNodeDef CurrentNode { get; private set; }
|
||||
public List<DialogueLogEntry> History { get; } = new();
|
||||
public bool IsOver { get; private set; }
|
||||
|
||||
/// <summary>Direct accessor to the runtime context — exposed so the UI
|
||||
/// can read <see cref="DialogueContext.ShopRequested"/> after option selection.</summary>
|
||||
public DialogueContext Context => _ctx;
|
||||
|
||||
public DialogueRunner(DialogueDef tree, DialogueContext ctx, ulong worldSeed)
|
||||
{
|
||||
_tree = tree ?? throw new System.ArgumentNullException(nameof(tree));
|
||||
_ctx = ctx ?? throw new System.ArgumentNullException(nameof(ctx));
|
||||
_worldSeed = worldSeed;
|
||||
_npcId = (ulong)ctx.Npc.Id;
|
||||
|
||||
_nodesById = tree.Nodes.ToDictionary(n => n.Id, System.StringComparer.OrdinalIgnoreCase);
|
||||
if (!_nodesById.TryGetValue(tree.Root, out var root))
|
||||
throw new System.InvalidOperationException($"Dialogue '{tree.Id}' root '{tree.Root}' missing");
|
||||
|
||||
CurrentNode = root;
|
||||
AppendNodeText(root);
|
||||
ApplyEffects(root.OnEnter);
|
||||
}
|
||||
|
||||
/// <summary>Options that pass their visibility predicates at the current turn.</summary>
|
||||
public IEnumerable<(int Index, DialogueOptionDef Option)> VisibleOptions()
|
||||
{
|
||||
for (int i = 0; i < CurrentNode.Options.Length; i++)
|
||||
{
|
||||
var opt = CurrentNode.Options[i];
|
||||
if (AreConditionsMet(opt.Conditions))
|
||||
yield return (i, opt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick an option by its index *into the original options array* (not
|
||||
/// the visible-only list — index stability across re-renders).
|
||||
/// </summary>
|
||||
public DialogueChooseResult ChooseOption(int optionIndex)
|
||||
{
|
||||
if (IsOver) return DialogueChooseResult.Closed("(dialogue is already over)");
|
||||
if (optionIndex < 0 || optionIndex >= CurrentNode.Options.Length)
|
||||
return DialogueChooseResult.Closed("(no such option)");
|
||||
var opt = CurrentNode.Options[optionIndex];
|
||||
if (!AreConditionsMet(opt.Conditions))
|
||||
return DialogueChooseResult.Closed("(option not available)");
|
||||
|
||||
// Append the player's choice to history.
|
||||
History.Add(new DialogueLogEntry(DialogueSpeaker.Pc, opt.Text));
|
||||
TurnIndex++;
|
||||
|
||||
// Skill-check option: roll, branch on success/failure, apply
|
||||
// appropriate effects/next.
|
||||
if (opt.SkillCheck is { } check)
|
||||
{
|
||||
var roll = ResolveSkillCheck(optionIndex, check);
|
||||
string log = $" [{check.Skill.ToUpperInvariant()} DC {check.Dc}] roll {roll.D20Raw} + {roll.Bonus} = {roll.Total} → {(roll.Succeeded ? "SUCCESS" : "FAILURE")}";
|
||||
History.Add(new DialogueLogEntry(DialogueSpeaker.Narration, log));
|
||||
ApplyEffects(roll.Succeeded ? opt.EffectsOnSuccess : opt.EffectsOnFailure);
|
||||
string nextId = roll.Succeeded ? opt.NextOnSuccess : opt.NextOnFailure;
|
||||
return AdvanceTo(nextId, roll);
|
||||
}
|
||||
|
||||
// Plain option.
|
||||
ApplyEffects(opt.Effects);
|
||||
return AdvanceTo(opt.Next, default);
|
||||
}
|
||||
|
||||
/// <summary>Force-close the dialogue (player pressed Esc).</summary>
|
||||
public void End()
|
||||
{
|
||||
if (IsOver) return;
|
||||
IsOver = true;
|
||||
}
|
||||
|
||||
// ── Conditions ───────────────────────────────────────────────────────
|
||||
|
||||
private bool AreConditionsMet(DialogueConditionDef[] conditions)
|
||||
{
|
||||
foreach (var c in conditions)
|
||||
if (!Evaluate(c)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool Evaluate(DialogueConditionDef c) => c.Kind.ToLowerInvariant() switch
|
||||
{
|
||||
"rep_at_least" => RepFor(c.Faction) >= c.Value,
|
||||
"rep_below" => RepFor(c.Faction) < c.Value,
|
||||
"has_item" => HasItem(c.Id),
|
||||
"not_has_item" => !HasItem(c.Id),
|
||||
"has_flag" => _ctx.Flags.TryGetValue(c.Flag, out int v) && v != 0,
|
||||
"not_has_flag" => !_ctx.Flags.TryGetValue(c.Flag, out int v2) || v2 == 0,
|
||||
"ability_min" => AbilityMod(c.Ability) >= c.Value,
|
||||
_ => true, // unknown kind → permissive (validated at content-load)
|
||||
};
|
||||
|
||||
private int RepFor(string faction)
|
||||
{
|
||||
if (string.IsNullOrEmpty(faction))
|
||||
return _ctx.EffectiveDispositionScore();
|
||||
return _ctx.Reputation.Factions.Get(faction);
|
||||
}
|
||||
|
||||
private bool HasItem(string itemId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(itemId)) return false;
|
||||
foreach (var inst in _ctx.Pc.Inventory.Items)
|
||||
if (string.Equals(inst.Def.Id, itemId, System.StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private int AbilityMod(string abilityRaw)
|
||||
{
|
||||
if (!System.Enum.TryParse<AbilityId>(abilityRaw, true, out var id)) return 0;
|
||||
return _ctx.Pc.Abilities.ModFor(id);
|
||||
}
|
||||
|
||||
// ── Effects ──────────────────────────────────────────────────────────
|
||||
|
||||
private void ApplyEffects(DialogueEffectDef[] effects)
|
||||
{
|
||||
foreach (var e in effects) ApplyEffect(e);
|
||||
}
|
||||
|
||||
private void ApplyEffect(DialogueEffectDef e)
|
||||
{
|
||||
switch (e.Kind.ToLowerInvariant())
|
||||
{
|
||||
case "set_flag":
|
||||
_ctx.Flags[e.Flag] = e.Value;
|
||||
break;
|
||||
case "clear_flag":
|
||||
_ctx.Flags.Remove(e.Flag);
|
||||
break;
|
||||
case "give_item":
|
||||
if (_ctx.Content.Items.TryGetValue(e.Id, out var giveDef))
|
||||
_ctx.Pc.Inventory.Add(giveDef, System.Math.Max(1, e.Qty));
|
||||
break;
|
||||
case "take_item":
|
||||
TakeFromInventory(e.Id, System.Math.Max(1, e.Qty));
|
||||
break;
|
||||
case "rep_event":
|
||||
if (e.Event is { } ev)
|
||||
SubmitRepEvent(ev);
|
||||
break;
|
||||
case "open_shop":
|
||||
_ctx.ShopRequested = true;
|
||||
break;
|
||||
case "start_quest":
|
||||
if (!string.IsNullOrEmpty(e.Quest))
|
||||
_ctx.StartQuestRequests.Add(e.Quest);
|
||||
break;
|
||||
case "give_xp":
|
||||
_ctx.Pc.Xp = System.Math.Max(0, _ctx.Pc.Xp + e.Xp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void TakeFromInventory(string itemId, int qty)
|
||||
{
|
||||
if (string.IsNullOrEmpty(itemId)) return;
|
||||
for (int i = _ctx.Pc.Inventory.Items.Count - 1; i >= 0 && qty > 0; i--)
|
||||
{
|
||||
var inst = _ctx.Pc.Inventory.Items[i];
|
||||
if (!string.Equals(inst.Def.Id, itemId, System.StringComparison.OrdinalIgnoreCase)) continue;
|
||||
int take = System.Math.Min(qty, inst.Qty);
|
||||
inst.Qty -= take;
|
||||
qty -= take;
|
||||
if (inst.Qty <= 0) _ctx.Pc.Inventory.Remove(inst);
|
||||
}
|
||||
}
|
||||
|
||||
private void SubmitRepEvent(DialogueRepEventDef ev)
|
||||
{
|
||||
if (!System.Enum.TryParse<RepEventKind>(ev.Kind, true, out var kind)) kind = RepEventKind.Dialogue;
|
||||
var live = new RepEvent
|
||||
{
|
||||
Kind = kind,
|
||||
FactionId = ev.Faction,
|
||||
RoleTag = string.IsNullOrEmpty(ev.RoleTag) ? _ctx.Npc.RoleTag : ev.RoleTag,
|
||||
Magnitude = ev.Magnitude,
|
||||
Note = ev.Note,
|
||||
OriginTileX = _ctx.PlayerWorldTileX,
|
||||
OriginTileY = _ctx.PlayerWorldTileY,
|
||||
TimestampSeconds = _ctx.WorldClockSeconds,
|
||||
};
|
||||
_ctx.Reputation.Submit(live, _ctx.Content.Factions);
|
||||
}
|
||||
|
||||
// ── Skill check ──────────────────────────────────────────────────────
|
||||
|
||||
private SkillCheckRoll ResolveSkillCheck(int optionIndex, DialogueSkillCheckDef check)
|
||||
{
|
||||
var key = (TurnIndex - 1, optionIndex);
|
||||
if (_rollCache.TryGetValue(key, out var cached)) return cached;
|
||||
|
||||
var skill = SkillIdExtensions.FromJson(check.Skill);
|
||||
int abilityMod = _ctx.Pc.Abilities.ModFor(skill.Ability());
|
||||
int profBonus = _ctx.Pc.SkillProficiencies.Contains(skill) ? _ctx.Pc.ProficiencyBonus : 0;
|
||||
int bonus = abilityMod + profBonus;
|
||||
|
||||
ulong seed = _worldSeed
|
||||
^ C.RNG_DIALOGUE
|
||||
^ _npcId
|
||||
^ ((ulong)(uint)key.Item1 << 8)
|
||||
^ ((ulong)(uint)key.Item2 << 24);
|
||||
var rng = new SeededRng(seed);
|
||||
int d20 = (int)(rng.NextUInt64() % 20UL) + 1;
|
||||
int total = d20 + bonus;
|
||||
|
||||
var roll = new SkillCheckRoll(skill, check.Dc, d20, bonus, total, total >= check.Dc);
|
||||
_rollCache[key] = roll;
|
||||
return roll;
|
||||
}
|
||||
|
||||
// ── Node transitions ─────────────────────────────────────────────────
|
||||
|
||||
private DialogueChooseResult AdvanceTo(string nextId, SkillCheckRoll skillRoll)
|
||||
{
|
||||
if (string.IsNullOrEmpty(nextId) || string.Equals(nextId, "<end>", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
IsOver = true;
|
||||
return DialogueChooseResult.Closed(skillRoll.Skill == 0 ? "" : "");
|
||||
}
|
||||
if (!_nodesById.TryGetValue(nextId, out var next))
|
||||
{
|
||||
IsOver = true;
|
||||
return DialogueChooseResult.Closed($"(missing node '{nextId}' — content bug)");
|
||||
}
|
||||
CurrentNode = next;
|
||||
AppendNodeText(next);
|
||||
ApplyEffects(next.OnEnter);
|
||||
return DialogueChooseResult.Advanced(skillRoll);
|
||||
}
|
||||
|
||||
private void AppendNodeText(DialogueNodeDef node)
|
||||
{
|
||||
var speaker = node.Speaker.ToLowerInvariant() switch
|
||||
{
|
||||
"pc" => DialogueSpeaker.Pc,
|
||||
"narration" => DialogueSpeaker.Narration,
|
||||
_ => DialogueSpeaker.Npc,
|
||||
};
|
||||
History.Add(new DialogueLogEntry(speaker, ResolvePlaceholders(node.Text)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Substitute placeholders in dialogue text. Phase 6 M3 supports
|
||||
/// {pc.name}, {npc.role}, {npc.name}, {disposition_label}.
|
||||
/// </summary>
|
||||
private string ResolvePlaceholders(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return text;
|
||||
return text
|
||||
.Replace("{pc.name}", _ctx.Pc is null ? "Wanderer" : "the wanderer", System.StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{npc.role}", _ctx.Npc.RoleTag ?? "", System.StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{npc.name}", _ctx.Npc.DisplayName, System.StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{disposition_label}",
|
||||
DispositionLabels.DisplayName(DispositionLabels.For(_ctx.EffectiveDispositionScore())),
|
||||
System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public enum DialogueSpeaker : byte { Npc, Pc, Narration }
|
||||
|
||||
public readonly record struct DialogueLogEntry(DialogueSpeaker Speaker, string Text);
|
||||
|
||||
public readonly record struct SkillCheckRoll(SkillId Skill, int Dc, int D20Raw, int Bonus, int Total, bool Succeeded);
|
||||
|
||||
public readonly struct DialogueChooseResult
|
||||
{
|
||||
public bool ClosedAfter { get; }
|
||||
public string Note { get; }
|
||||
public SkillCheckRoll? Roll { get; }
|
||||
|
||||
private DialogueChooseResult(bool closed, string note, SkillCheckRoll? roll)
|
||||
{
|
||||
ClosedAfter = closed;
|
||||
Note = note;
|
||||
Roll = roll;
|
||||
}
|
||||
|
||||
public static DialogueChooseResult Closed(string note)
|
||||
=> new(true, note, null);
|
||||
|
||||
public static DialogueChooseResult Advanced(SkillCheckRoll roll)
|
||||
=> new(false, "", roll.Dc == 0 ? null : roll);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Theriapolis.Core.Rules.Reputation;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Dialogue;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M3 — disposition-driven shop modifiers per the plan §4.6.
|
||||
///
|
||||
/// NEMESIS → service refused
|
||||
/// HOSTILE → service refused
|
||||
/// ANTAGONISTIC..UNFRIENDLY → +25% prices, +10% sell discount
|
||||
/// NEUTRAL → base prices
|
||||
/// FAVORABLE → -10% buy
|
||||
/// FRIENDLY → -20% buy
|
||||
/// ALLIED → -30% buy
|
||||
/// CHAMPION → -40% buy
|
||||
///
|
||||
/// Phase 6 M3 ships buy-side adjustment only; sell-side is mirrored at
|
||||
/// 50% of the buy modifier so a friendly merchant pays a small premium
|
||||
/// without dual-tunable knobs.
|
||||
/// </summary>
|
||||
public static class ShopPricing
|
||||
{
|
||||
/// <summary>True if the player can shop at all given the disposition score.</summary>
|
||||
public static bool ServiceAvailable(int dispositionScore)
|
||||
{
|
||||
var label = DispositionLabels.For(dispositionScore);
|
||||
return label != DispositionLabel.Nemesis && label != DispositionLabel.Hostile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Multiplier applied to a price the player pays. 1.0 = base; >1 =
|
||||
/// markup; <1 = discount. Caller multiplies the item's listed cost
|
||||
/// and rounds.
|
||||
/// </summary>
|
||||
public static float BuyMultiplier(int dispositionScore)
|
||||
{
|
||||
var label = DispositionLabels.For(dispositionScore);
|
||||
return label switch
|
||||
{
|
||||
DispositionLabel.Nemesis => 99f, // shouldn't be called; sentinel
|
||||
DispositionLabel.Hostile => 99f,
|
||||
DispositionLabel.Antagonistic => 1.25f,
|
||||
DispositionLabel.Unfriendly => 1.25f,
|
||||
DispositionLabel.Neutral => 1.00f,
|
||||
DispositionLabel.Favorable => 0.90f,
|
||||
DispositionLabel.Friendly => 0.80f,
|
||||
DispositionLabel.Allied => 0.70f,
|
||||
DispositionLabel.Champion => 0.60f,
|
||||
_ => 1.00f,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Multiplier applied to a price the merchant pays the player on sell-back.</summary>
|
||||
public static float SellMultiplier(int dispositionScore)
|
||||
{
|
||||
// Mirror buy modifier toward 1.0 by 50%: friendly buy = 0.80 →
|
||||
// friendly sell = 0.60 (you still take a haircut), antagonistic
|
||||
// buy = 1.25 → antagonistic sell = 0.30.
|
||||
var label = DispositionLabels.For(dispositionScore);
|
||||
return label switch
|
||||
{
|
||||
DispositionLabel.Antagonistic => 0.35f,
|
||||
DispositionLabel.Unfriendly => 0.40f,
|
||||
DispositionLabel.Neutral => 0.50f,
|
||||
DispositionLabel.Favorable => 0.55f,
|
||||
DispositionLabel.Friendly => 0.60f,
|
||||
DispositionLabel.Allied => 0.65f,
|
||||
DispositionLabel.Champion => 0.70f,
|
||||
_ => 0f, // refused at Hostile / Nemesis
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Buy price for one unit (rounded up). Cost is in the item's "cost_fang" or equivalent unit.</summary>
|
||||
public static int BuyPriceFor(int baseCost, int dispositionScore)
|
||||
=> System.Math.Max(1, (int)System.Math.Ceiling(baseCost * BuyMultiplier(dispositionScore)));
|
||||
|
||||
/// <summary>Sell price for one unit (rounded down).</summary>
|
||||
public static int SellPriceFor(int baseCost, int dispositionScore)
|
||||
=> System.Math.Max(0, (int)System.Math.Floor(baseCost * SellMultiplier(dispositionScore)));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Entities;
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Rules.Reputation;
|
||||
using Theriapolis.Core.Time;
|
||||
using Theriapolis.Core.World;
|
||||
using Theriapolis.Core.World.Settlements;
|
||||
|
||||
namespace Theriapolis.Core.Rules.Quests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M4 — read/write window the QuestEngine uses to evaluate
|
||||
/// conditions and apply effects. Deliberately holds references rather
|
||||
/// than copies so engine ticks see live state.
|
||||
/// </summary>
|
||||
public sealed class QuestContext
|
||||
{
|
||||
public ContentResolver Content { get; }
|
||||
public ActorManager Actors { get; }
|
||||
public PlayerReputation Reputation { get; }
|
||||
public Dictionary<string, int> Flags { get; }
|
||||
public AnchorRegistry Anchors { get; }
|
||||
public WorldClock Clock { get; }
|
||||
public WorldState World { get; }
|
||||
public Rules.Character.Character? PlayerCharacter { get; set; }
|
||||
|
||||
/// <summary>Most recent dialogue node id reached, surfaced by InteractionScreen for the dialogue_choice condition.</summary>
|
||||
public string LastDialogueNodeReached { get; set; } = "";
|
||||
|
||||
public QuestContext(ContentResolver content, ActorManager actors,
|
||||
PlayerReputation rep, Dictionary<string, int> flags,
|
||||
AnchorRegistry anchors, WorldClock clock, WorldState world)
|
||||
{
|
||||
Content = content;
|
||||
Actors = actors;
|
||||
Reputation = rep;
|
||||
Flags = flags;
|
||||
Anchors = anchors;
|
||||
Clock = clock;
|
||||
World = world;
|
||||
}
|
||||
|
||||
public bool HasItem(string itemId)
|
||||
{
|
||||
if (PlayerCharacter is null) return false;
|
||||
foreach (var inst in PlayerCharacter.Inventory.Items)
|
||||
if (string.Equals(inst.Def.Id, itemId, System.StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Position of the live player actor in tactical-tile (= world-pixel)
|
||||
/// space, or null if the actor isn't yet spawned.
|
||||
/// </summary>
|
||||
public (int x, int y)? PlayerTacticalPos()
|
||||
{
|
||||
if (Actors.Player is null) return null;
|
||||
return ((int)Actors.Player.Position.X, (int)Actors.Player.Position.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// World-tile Chebyshev distance from the player to the settlement
|
||||
/// with the given id, or null if the player or settlement isn't
|
||||
/// resolvable.
|
||||
/// </summary>
|
||||
public int? PlayerDistanceToSettlement(int settlementId)
|
||||
{
|
||||
if (Actors.Player is null) return null;
|
||||
Settlement? s = null;
|
||||
foreach (var sx in World.Settlements)
|
||||
if (sx.Id == settlementId) { s = sx; break; }
|
||||
if (s is null) return null;
|
||||
int playerWX = (int)Actors.Player.Position.X / C.WORLD_TILE_PIXELS;
|
||||
int playerWY = (int)Actors.Player.Position.Y / C.WORLD_TILE_PIXELS;
|
||||
int dx = System.Math.Abs(playerWX - s.TileX);
|
||||
int dy = System.Math.Abs(playerWY - s.TileY);
|
||||
return System.Math.Max(dx, dy);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user