Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7cadaeb68 | |||
| 97b49d4145 | |||
| 39117a09ed | |||
| 0ab4715aee | |||
| 479899d3d1 | |||
| 66055f9549 | |||
| 067038de45 | |||
| e1fb988969 | |||
| 44b2ec111f | |||
| 29657f73f8 |
@@ -3,6 +3,7 @@
|
||||
"id": "canidae",
|
||||
"name": "Canidae",
|
||||
"kind": "predator",
|
||||
"description": "\"We were the first to hunt together, the first to howl in harmony, and the first to sit across from prey and call it diplomacy.\"\n\nPack-hunters who became civilization-builders, defined by social cohesion, hierarchical instinct, and supernatural scent-reading. Disproportionately represented in military, law enforcement, and governance.",
|
||||
"ability_mods": { "CON": 1, "WIS": 1 },
|
||||
"languages": ["common", "canid"],
|
||||
"traits": [
|
||||
@@ -19,6 +20,7 @@
|
||||
"id": "felidae",
|
||||
"name": "Felidae",
|
||||
"kind": "predator",
|
||||
"description": "\"We do not need your pack. We do not need your herd. We need only what is ours, and we will take it when you look away.\"\n\nPrecision predators built for explosive power, preternatural reflexes, and absolute independence. They dominate fields requiring solo brilliance — surgery, assassination, fine art, engineering.",
|
||||
"ability_mods": { "DEX": 1, "CHA": 1 },
|
||||
"languages": ["common", "felid"],
|
||||
"traits": [
|
||||
@@ -36,22 +38,24 @@
|
||||
"id": "mustelidae",
|
||||
"name": "Mustelidae",
|
||||
"kind": "predator",
|
||||
"description": "\"Small doesn't mean weak. It means you have to be smarter, meaner, and faster than everyone who thinks size is the only thing that matters.\"\n\nThe most physically diverse Clade — weasel-folk, ferret-folk, badger-folk, wolverine-folk, otter-folk — united by furnace metabolisms, ferocity wildly disproportionate to body size, and a reputation for being impossible to pin down.",
|
||||
"ability_mods": { "DEX": 1, "INT": 1 },
|
||||
"languages": ["common", "mustelid"],
|
||||
"traits": [
|
||||
{ "id": "sinuous_frame", "name": "Sinuous Frame", "description": "Squeeze through openings sized for one size category smaller without penalty. Advantage on checks to escape grapples and restraints." },
|
||||
{ "id": "burning_metabolism", "name": "Burning Metabolism", "description": "Advantage on saves vs. cold and exhaustion. Requires double rations to function (see equipment costs)." },
|
||||
{ "id": "ferocity", "name": "Ferocity", "description": "When reduced below half HP, deal +1 damage on melee attacks until end of next turn. Triggers once per long rest." }
|
||||
{ "id": "metabolic_furnace", "name": "Metabolic Furnace", "description": "Advantage on CON saves against cold environments and cold-based damage. (You must consume twice the normal daily ration to fuel this — see Burn Rate.)" },
|
||||
{ "id": "sinuous_body", "name": "Sinuous Body", "description": "Move through spaces sized one category smaller than your actual size without squeezing penalties. Advantage on checks to escape grapples." },
|
||||
{ "id": "ferocity", "name": "Ferocity", "description": "When reduced to half HP or below, gain +2 to melee attack rolls. Mustelidae don't retreat — they get angrier." }
|
||||
],
|
||||
"detriments": [
|
||||
{ "id": "high_metabolism", "name": "High Metabolism", "description": "Requires double rations daily. Without enough food, gain a level of exhaustion every 12 hours instead of 24." },
|
||||
{ "id": "scent_marker", "name": "Scent Marker", "description": "Mustelid musk is unmistakable and difficult to mask. Disadvantage on Stealth checks against creatures with scent abilities unless you have a deep-cover scent-mask active." }
|
||||
{ "id": "burn_rate", "name": "Burn Rate", "description": "You require double standard rations. Without them, gain one level of exhaustion per day of insufficient feeding. Starvation hits Mustelidae twice as fast as any other Clade." },
|
||||
{ "id": "short_fuse", "name": "Short Fuse", "description": "Disadvantage on WIS saves against effects that provoke rage, aggression, or recklessness. The gap between 'annoyed' and 'fighting' is dangerously short." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ursidae",
|
||||
"name": "Ursidae",
|
||||
"kind": "predator",
|
||||
"description": "\"The bear doesn't chase. The bear waits. And eventually, everything comes to the bear.\"\n\nThe largest sentient Clade. Slow to anger, slow to move, slow to decide — and catastrophically powerful when any of those things finally tips. Frequently underestimated intellectually because size reads as simplicity; in truth, Ursidae civilizations produced some of the finest philosophy, architecture, and brewing in the world.",
|
||||
"ability_mods": { "DEX": -1, "CON": 2 },
|
||||
"languages": ["common", "ursid"],
|
||||
"traits": [
|
||||
@@ -68,6 +72,7 @@
|
||||
"id": "cervidae",
|
||||
"name": "Cervidae",
|
||||
"kind": "prey",
|
||||
"description": "\"We were the first to know that every shadow could be death. That knowledge made us sharper than any claw.\"\n\nDeer-folk, elk-folk, moose-folk — defined by hypervigilance, explosive athleticism, and a cultural philosophy forged in millennia of being hunted. They have turned survival into a technology, a philosophy, and occasionally a weapon.",
|
||||
"ability_mods": { "DEX": 1, "WIS": 1 },
|
||||
"languages": ["common", "cervid"],
|
||||
"traits": [
|
||||
@@ -84,6 +89,7 @@
|
||||
"id": "bovidae",
|
||||
"name": "Bovidae",
|
||||
"kind": "prey",
|
||||
"description": "\"The wall holds because we hold it. When the wolves came, we didn't run. We stood, shoulder to shoulder, horns out, and we held.\"\n\nBull-folk, bison-folk, ram-folk, goat-folk — the heaviest prey Clade, defined by communal endurance, raw physical power, and a cultural identity built on the principle that strength means staying.",
|
||||
"ability_mods": { "STR": 1, "CON": 1 },
|
||||
"languages": ["common", "bovid"],
|
||||
"traits": [
|
||||
@@ -100,6 +106,7 @@
|
||||
"id": "leporidae",
|
||||
"name": "Leporidae",
|
||||
"kind": "prey",
|
||||
"description": "\"We don't fight. We outrun, outbreed, outthink, and outlast. You want to call that weakness? We'll be here when you're gone.\"\n\nThe smallest common prey Clade — rabbit-folk and hare-folk — surviving through explosive speed, prodigious reproduction, community networks, and a cultural genius for making themselves indispensable. They dominate medicine, communications, logistics, and information brokering.",
|
||||
"ability_mods": { "STR": -1, "DEX": 2 },
|
||||
"languages": ["common", "leporid"],
|
||||
"traits": [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{
|
||||
"id": "fangsworn",
|
||||
"name": "Fangsworn",
|
||||
"description": "\"I swore my fangs to something bigger than myself. Whether that was smart is a separate question.\"\n\nThe professional warrior. Trained combatants — soldiers, mercenaries, duelists, bodyguards, career fighters who turned violence into discipline. Fangsworn don't rely on rage or instinct; they rely on repetition, conditioning, and the brutal arithmetic of who hits harder and more often. Every army needs a backbone, and it's always been teeth.",
|
||||
"hit_die": 10,
|
||||
"primary_ability": ["STR", "DEX"],
|
||||
"saves": ["STR", "CON"],
|
||||
@@ -9,7 +10,7 @@
|
||||
"weapon_proficiencies": ["simple", "martial", "natural"],
|
||||
"tool_proficiencies": [],
|
||||
"skills_choose": 2,
|
||||
"skill_options": ["athletics", "intimidation", "perception", "survival", "animal_handling"],
|
||||
"skill_options": ["athletics", "animal_handling", "brawl", "force", "intimidation", "marksmanship", "pain_tolerance", "perception", "survival"],
|
||||
"subclass_ids": ["pack_forged", "lone_fang"],
|
||||
"starting_kit": [
|
||||
{ "item_id": "rend_sword", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||
@@ -59,6 +60,7 @@
|
||||
{
|
||||
"id": "bulwark",
|
||||
"name": "Bulwark",
|
||||
"description": "\"Running is smart. Standing is stupid. I do it anyway, because someone behind me can't run.\"\n\nThe wall. The shield. The one who stays when everyone else runs. Bulwarks are defenders first — born from the herd-fighting traditions of prey Clades, where bovid wall-tactics, cervid herd-coordination, and leporid community-shielding fused into a single protective doctrine. They hold the line, they take the hit, and they make sure the people behind them get to live.",
|
||||
"hit_die": 12,
|
||||
"primary_ability": ["CON"],
|
||||
"saves": ["CON", "CHA"],
|
||||
@@ -66,7 +68,7 @@
|
||||
"weapon_proficiencies": ["simple", "martial", "natural"],
|
||||
"tool_proficiencies": [],
|
||||
"skills_choose": 2,
|
||||
"skill_options": ["athletics", "insight", "intimidation", "medicine", "perception"],
|
||||
"skill_options": ["athletics", "endurance", "fortitude", "hardiness", "insight", "intimidation", "medicine", "pain_tolerance", "perception"],
|
||||
"subclass_ids": ["herd_wall", "antler_guard"],
|
||||
"starting_kit": [
|
||||
{ "item_id": "hoof_club", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||
@@ -118,6 +120,7 @@
|
||||
{
|
||||
"id": "feral",
|
||||
"name": "Feral",
|
||||
"description": "\"You think civilization fixed us? It just taught us to hold our breath.\"\n\nThe old brain. The one before words. Every sentient creature carries the Feral Age inside them — the pre-sapient animal, pure instinct. Most people keep that door locked. Ferals open it. The implications disturb everyone, especially prey-Clade Ferals whose ancestors survived by suppressing those exact urges; the rage of a hunted thing that has decided, today, to stop running is its own kind of terrible.",
|
||||
"hit_die": 12,
|
||||
"primary_ability": ["STR", "CON"],
|
||||
"saves": ["STR", "CON"],
|
||||
@@ -125,7 +128,7 @@
|
||||
"weapon_proficiencies": ["simple", "natural"],
|
||||
"tool_proficiencies": [],
|
||||
"skills_choose": 2,
|
||||
"skill_options": ["athletics", "intimidation", "nature", "perception", "survival"],
|
||||
"skill_options": ["athletics", "brawl", "endurance", "fortitude", "hardiness", "haulage", "intimidation", "nature", "perception", "survival"],
|
||||
"subclass_ids": ["blood_memory", "stampede_heart"],
|
||||
"starting_kit": [
|
||||
{ "item_id": "paw_axe", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||
@@ -180,6 +183,7 @@
|
||||
{
|
||||
"id": "shadow_pelt",
|
||||
"name": "Shadow-Pelt",
|
||||
"description": "\"Everyone watches the fangs. Nobody watches the shadow behind the fangs.\"\n\nThieves, assassins, spies, scouts. Shadow-Pelts specialize in precision over force, infiltration over confrontation. The calling is cross-Clade by design — felid stealth, mustelid sinuousness, the Leporid art of being small enough to overlook. Any species that can move quietly and think two steps ahead can take this path. Most cities pretend they don't exist. Most cities are wrong.",
|
||||
"hit_die": 8,
|
||||
"primary_ability": ["DEX"],
|
||||
"saves": ["DEX", "INT"],
|
||||
@@ -187,7 +191,7 @@
|
||||
"weapon_proficiencies": ["simple", "hand_crossbow", "short_sword", "rapier", "natural"],
|
||||
"tool_proficiencies": ["thieves_tools"],
|
||||
"skills_choose": 4,
|
||||
"skill_options": ["acrobatics", "athletics", "deception", "insight", "intimidation", "investigation", "perception", "persuasion", "sleight_of_hand", "stealth"],
|
||||
"skill_options": ["acrobatics", "athletics", "build_read", "deception", "driving", "insight", "intimidation", "investigation", "marksmanship", "perception", "persuasion", "sleight_of_hand", "stealth"],
|
||||
"subclass_ids": ["noseblind", "ambush_artist"],
|
||||
"starting_kit": [
|
||||
{ "item_id": "thorn_blade", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||
@@ -243,6 +247,7 @@
|
||||
{
|
||||
"id": "scent_broker",
|
||||
"name": "Scent-Broker",
|
||||
"description": "\"You think information is what you hear? What you read? Amateur. Information is what you smell when someone walks into the room. Fear. Lies. Lust. Disease. Lineage. Every creature broadcasts their secrets with every breath. I just learned to listen.\"\n\nA calling unique to Theriapolis. Scent-Brokers are intelligence operatives, diplomats, perfume-mages — readers of pheromones and emotional scent. They weaponize the gap between what people say and what their bodies tell on them. Half merchant, half spy, all nose.",
|
||||
"hit_die": 8,
|
||||
"primary_ability": ["WIS"],
|
||||
"saves": ["WIS", "CHA"],
|
||||
@@ -250,7 +255,7 @@
|
||||
"weapon_proficiencies": ["simple", "natural"],
|
||||
"tool_proficiencies": ["alchemists_supplies", "perfumers_kit"],
|
||||
"skills_choose": 3,
|
||||
"skill_options": ["deception", "insight", "investigation", "medicine", "perception", "persuasion", "stealth"],
|
||||
"skill_options": ["build_read", "deception", "insight", "investigation", "lung_craft", "medicine", "perception", "persuasion", "scent_speak", "stealth"],
|
||||
"subclass_ids": ["perfumer", "tracker"],
|
||||
"starting_kit": [
|
||||
{ "item_id": "fang_knife", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||
@@ -302,6 +307,7 @@
|
||||
},
|
||||
{
|
||||
"id": "covenant_keeper",
|
||||
"description": "\"The Covenant says we don't eat the speaking ones. I'm here to make sure everyone remembers.\"\n\nThe oath made flesh. The law with teeth. Covenant-Keepers are part judge, part priest, part enforcer — their authority comes from the Covenant of Claws, the sacred-legal compact that defines sentience, prohibits cannibalism between Clades, and binds civilization together. Where the Covenant is honored, they are diplomats and arbiters. Where it is broken, they are the answer.",
|
||||
"name": "Covenant-Keeper",
|
||||
"hit_die": 10,
|
||||
"primary_ability": ["CHA"],
|
||||
@@ -310,7 +316,7 @@
|
||||
"weapon_proficiencies": ["simple", "martial", "natural"],
|
||||
"tool_proficiencies": [],
|
||||
"skills_choose": 2,
|
||||
"skill_options": ["athletics", "insight", "intimidation", "medicine", "persuasion", "religion"],
|
||||
"skill_options": ["athletics", "brawl", "force", "insight", "intimidation", "lung_craft", "medicine", "persuasion", "religion", "scent_speak"],
|
||||
"subclass_ids": ["the_warden", "the_bridge"],
|
||||
"starting_kit": [
|
||||
{ "item_id": "rend_sword", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||
@@ -362,6 +368,7 @@
|
||||
},
|
||||
{
|
||||
"id": "muzzle_speaker",
|
||||
"description": "\"The first howl that meant something other than hunger — that was the beginning of everything. I'm what happens when you take that gift seriously.\"\n\nVoice is power, always has been. Muzzle-Speakers are bards evolved for a world where vocalization varies wildly by Clade and subsonic communication is ancestral. They use voice, cadence, rhythm, and inter-species emotive frequencies to inspire, manipulate, heal, and harm. Songs that calm a stampeding herd, war-howls that rally a fractured pack, lullabies sung in registers older than language.",
|
||||
"name": "Muzzle-Speaker",
|
||||
"hit_die": 8,
|
||||
"primary_ability": ["CHA"],
|
||||
@@ -370,7 +377,7 @@
|
||||
"weapon_proficiencies": ["simple", "natural"],
|
||||
"tool_proficiencies": ["musical_instrument", "musical_instrument_2", "musical_instrument_3"],
|
||||
"skills_choose": 3,
|
||||
"skill_options": ["acrobatics", "animal_handling", "arcana", "athletics", "deception", "history", "insight", "intimidation", "investigation", "medicine", "nature", "perception", "performance", "persuasion", "religion", "sleight_of_hand", "stealth", "survival"],
|
||||
"skill_options": ["acrobatics", "animal_handling", "arcana", "athletics", "brawl", "build_read", "deception", "driving", "endurance", "force", "fortitude", "hardiness", "haulage", "history", "insight", "intimidation", "investigation", "lung_craft", "marksmanship", "medicine", "nature", "pain_tolerance", "perception", "performance", "persuasion", "religion", "scent_speak", "sleight_of_hand", "stealth", "survival"],
|
||||
"subclass_ids": ["warhorn", "whisperfur"],
|
||||
"starting_kit": [
|
||||
{ "item_id": "fang_knife", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||
@@ -424,6 +431,7 @@
|
||||
},
|
||||
{
|
||||
"id": "claw_wright",
|
||||
"description": "\"Your body doesn't fit the chair? I'll build you a better chair. Your paws can't hold the scalpel? I'll build you a better scalpel. The world wasn't designed for you? I'll redesign the world.\"\n\nThe paw that builds. Engineers, inventors, adaptive-technology specialists. In a world of body diversity — paws, hooves, talons, beaks, dewclaws, prehensile tails — generalist designers are invaluable. Half mechanical, half medical, all problem-solver. They work in armories, workshops, prosthetics labs, and the back rooms of underground hybrid clinics.",
|
||||
"name": "Claw-Wright",
|
||||
"hit_die": 8,
|
||||
"primary_ability": ["INT"],
|
||||
@@ -432,7 +440,7 @@
|
||||
"weapon_proficiencies": ["simple", "natural", "firearms"],
|
||||
"tool_proficiencies": ["tinkers_tools", "artisans_tools", "artisans_tools_2"],
|
||||
"skills_choose": 3,
|
||||
"skill_options": ["arcana", "investigation", "medicine", "nature", "perception", "sleight_of_hand"],
|
||||
"skill_options": ["arcana", "build_read", "driving", "force", "haulage", "investigation", "medicine", "nature", "perception", "sleight_of_hand"],
|
||||
"subclass_ids": ["combat_engineer", "body_wright"],
|
||||
"starting_kit": [
|
||||
{ "item_id": "hoof_club", "qty": 1, "auto_equip": true, "equip_slot": "main_hand" },
|
||||
|
||||
+120
-27
@@ -3,6 +3,7 @@
|
||||
"id": "wolf",
|
||||
"clade_id": "canidae",
|
||||
"name": "Wolf-Folk",
|
||||
"description": "\"The apex of the Canid line. Big bodies, bigger presence. Every room they enter gets smaller.\"\n\nBroad-shouldered, heavy-boned, thick through chest and thigh. Dense double-coat in timber grey to midnight black, tawny brown to arctic white; tall triangular ears; expressive tail whose carriage telegraphs status reflexively.",
|
||||
"size": "medium_large",
|
||||
"ability_mods": { "STR": 1 },
|
||||
"base_speed_ft": 30,
|
||||
@@ -20,6 +21,7 @@
|
||||
"id": "fox",
|
||||
"clade_id": "canidae",
|
||||
"name": "Fox-Folk",
|
||||
"description": "\"Smaller, quicker, cleverer — and never, ever forgetting that the wolves looked down on them for it.\"\n\nLean, narrow-framed, built for speed and agility over power. Typically red-orange with cream underbelly and black stockings, but silver, cross, marble, and arctic white morphs are common. Signature bushy white-tipped tail.",
|
||||
"size": "medium",
|
||||
"ability_mods": { "DEX": 1 },
|
||||
"base_speed_ft": 35,
|
||||
@@ -37,6 +39,7 @@
|
||||
"id": "coyote",
|
||||
"clade_id": "canidae",
|
||||
"name": "Coyote-Folk",
|
||||
"description": "\"Adaptable. Resourceful. Everywhere you don't want them and thriving anyway.\"\n\nWiry and rangy — built like they survive on scraps and spite. Grizzled tawny-grey, sometimes sandy brown or dusty red. Lean musculature, narrow muzzle, unsettlingly direct yellow-gold gaze.",
|
||||
"size": "medium",
|
||||
"ability_mods": { "CHA": 1 },
|
||||
"base_speed_ft": 30,
|
||||
@@ -54,23 +57,43 @@
|
||||
"id": "lion",
|
||||
"clade_id": "felidae",
|
||||
"name": "Lion-Folk",
|
||||
"description": "\"The only Felidae who learned to be social — and they never let anyone forget they're still cats.\"\n\nPowerfully built, broad through chest and shoulder. Short tawny-gold to warm brown fur. Males sport manes ranging tawny gold to deep brown-black, framing the face and shielding the throat; the gaze of something that has never been prey.",
|
||||
"size": "medium_large",
|
||||
"ability_mods": { "STR": 1 },
|
||||
"base_speed_ft": 30,
|
||||
"traits": [
|
||||
{ "id": "commanding_presence", "name": "Commanding Presence", "description": "Proficiency in Intimidation (expertise if already proficient). When intimidating, may roar — creatures within 15 ft. who hear it make a WIS save (DC = 8 + prof + CHA) or are frightened until end of next turn. Once per short rest." },
|
||||
{ "id": "pride_fighter", "name": "Pride Fighter", "description": "Lion-folk can both grant and benefit from flanking. When you and an ally are adjacent to the same enemy, both gain +2 to attack rolls against that enemy." },
|
||||
{ "id": "mane_guard", "name": "Mane Guard", "description": "+1 AC against attacks targeting the neck or throat." }
|
||||
{ "id": "pride_fighter", "name": "Pride Fighter", "description": "Lion-folk can both grant and benefit from flanking. When you and an ally are adjacent to the same enemy, both gain +2 to attack rolls against that enemy." }
|
||||
],
|
||||
"detriments": [
|
||||
{ "id": "territorial_ego", "name": "Territorial Ego", "description": "Disadvantage on CHA (Persuasion) when negotiating shared resources, territory, or leadership positions." },
|
||||
{ "id": "heat_lethargy", "name": "Heat Lethargy", "description": "In temperatures above 90°F, CON save (DC 10) every hour of strenuous activity or gain a level of exhaustion." }
|
||||
],
|
||||
"variant_axis": "sex",
|
||||
"variants": [
|
||||
{
|
||||
"id": "male",
|
||||
"name": "Maned",
|
||||
"traits": [
|
||||
{ "id": "mane_guard", "name": "Mane Guard", "description": "+1 AC against attacks targeting the neck or throat. The mane is armor that grew there on its own." }
|
||||
],
|
||||
"detriments": []
|
||||
},
|
||||
{
|
||||
"id": "female",
|
||||
"name": "Maneless",
|
||||
"traits": [
|
||||
{ "id": "huntress_reflexes", "name": "Huntress Reflexes", "description": "Base speed +5 ft. Advantage on initiative rolls. Lionesses do the hunting in the pride for a reason." }
|
||||
],
|
||||
"detriments": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "leopard",
|
||||
"clade_id": "felidae",
|
||||
"name": "Leopard-Folk",
|
||||
"description": "\"You won't hear them. You won't see them. And then it's too late for either.\"\n\nLean, densely muscled, compact — every ounce of weight is functional. Golden-yellow to tawny with distinctive black rosettes (melanistic individuals all-black). Powerful shoulders for climbing and grappling.",
|
||||
"size": "medium",
|
||||
"ability_mods": { "DEX": 1 },
|
||||
"base_speed_ft": 30,
|
||||
@@ -88,6 +111,7 @@
|
||||
"id": "housecat",
|
||||
"clade_id": "felidae",
|
||||
"name": "Housecat-Folk",
|
||||
"description": "\"Small. Overlooked. Absolutely lethal in ways you'll never trace back to them.\"\n\nFine-boned and quick, built for spaces no one else fits into. Enormous coat variety — tabby, calico, solid, tuxedo, tortoiseshell, pointed. Extremely flexible spine; can fit through any opening their skull will pass.",
|
||||
"size": "small",
|
||||
"ability_mods": { "INT": 1 },
|
||||
"base_speed_ft": 30,
|
||||
@@ -105,93 +129,134 @@
|
||||
"id": "ferret",
|
||||
"clade_id": "mustelidae",
|
||||
"name": "Ferret-Folk",
|
||||
"description": "\"Charming, manic, and fundamentally incapable of leaving anything alone.\"\n\nLong-bodied and slinky, built like the skeleton is a suggestion. Sable, albino, champagne, silver, dark-eyed white; mask-face markings common. The whole body moves like a sine wave; sweet-musky scent fills a room.",
|
||||
"size": "small",
|
||||
"ability_mods": { "CHA": 1 },
|
||||
"base_speed_ft": 30,
|
||||
"traits": [
|
||||
{ "id": "weaver", "name": "Weaver", "description": "Move through any opening at least 6 inches wide. No penalty for combat in cramped spaces (tunnels, crawlspaces)." },
|
||||
{ "id": "social_charm", "name": "Social Charm", "description": "Advantage on Deception and Persuasion checks against creatures who underestimate you for your size." }
|
||||
{ "id": "war_dance", "name": "War Dance", "description": "Bonus action: weave erratically. Until the start of your next turn, attack rolls against you have disadvantage if you moved at least 15 ft. this turn. Uses equal to proficiency bonus per long rest." },
|
||||
{ "id": "tunnel_runner", "name": "Tunnel Runner", "description": "Burrow speed of 15 ft. in loose soil or sand. Navigate tunnels and underground spaces without penalty. Advantage on checks to find or create underground passages." },
|
||||
{ "id": "irrepressible", "name": "Irrepressible", "description": "Advantage on saves against the frightened condition. Ferret-folk have a pathological inability to take threats as seriously as they should." }
|
||||
],
|
||||
"detriments": [
|
||||
{ "id": "small_frame", "name": "Small Frame", "description": "Carrying capacity halved. Heavy weapons cannot be used effectively." }
|
||||
{ "id": "attention_deficit", "name": "Attention Deficit", "description": "Disadvantage on checks requiring sustained concentration outside of combat (extended research, long stakeouts, detailed crafting over 4+ hours). Your focus is explosive, not sustained." },
|
||||
{ "id": "musk_broadcast", "name": "Musk Broadcast", "description": "Under stress, fear, or arousal, your scent intensifies involuntarily. Stealth checks in these states are made with disadvantage, and creatures with scent abilities automatically know your emotional state." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "badger",
|
||||
"clade_id": "mustelidae",
|
||||
"name": "Badger-Folk",
|
||||
"description": "\"Not big. Not fast. Just absolutely unwilling to stop.\"\n\nStocky, low center of gravity, dense muscle on a compact frame with disproportionately wide shoulders. Coarse bristly fur, classic black-and-white facial striping. Heavy digging claws. Built like a bunker with teeth.",
|
||||
"size": "medium",
|
||||
"ability_mods": { "CON": 1 },
|
||||
"base_speed_ft": 25,
|
||||
"base_speed_ft": 30,
|
||||
"traits": [
|
||||
{ "id": "burrower", "name": "Burrower", "description": "Burrow speed of 10 ft. through loose soil, sand, or snow. Cannot burrow through stone or hardpacked earth." },
|
||||
{ "id": "tenacious_grip", "name": "Tenacious Grip", "description": "Advantage on grapple attempts. Targets you grapple have disadvantage on checks to escape." }
|
||||
{ "id": "immovable", "name": "Immovable", "description": "Advantage on STR and CON saves against being knocked prone, pushed, or forcibly moved. Your low center of gravity and dense build make displacement extremely difficult." },
|
||||
{ "id": "digging_claws", "name": "Digging Claws", "description": "Burrow speed of 20 ft. Claw attacks deal 1d6 + STR slashing. Advantage on checks to break through barriers, dig through obstacles, or demolish structures." },
|
||||
{ "id": "relentless_endurance", "name": "Relentless Endurance", "description": "When you take damage that would reduce you to 0 HP, reaction: CON save (DC = 10 + damage taken). On success, drop to 1 HP instead. Once per long rest." }
|
||||
],
|
||||
"detriments": [
|
||||
{ "id": "stocky_build", "name": "Stocky Build", "description": "Base speed 25 ft. Disadvantage on long-jump checks." }
|
||||
{ "id": "tunnel_vision", "name": "Tunnel Vision", "description": "When engaged with a single target, disadvantage on Perception checks to notice other threats. Badger-folk lock on and everything else goes dark." },
|
||||
{ "id": "antisocial_default", "name": "Antisocial Default", "description": "Disadvantage on CHA checks in social gatherings of 6+ people. Not shy — actively irritated by crowds." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wolverine",
|
||||
"clade_id": "mustelidae",
|
||||
"name": "Wolverine-Folk",
|
||||
"description": "\"Nature's proof that fury is a viable survival strategy.\"\n\nDense, heavy-boned, thick through torso and limbs. Dark brown to black, often with a pale lateral stripe shoulder to hip. Frost-resistant ruff. The eyes of something that has calculated whether it can take you and decided yes.",
|
||||
"size": "medium_large",
|
||||
"ability_mods": { "STR": 1 },
|
||||
"base_speed_ft": 30,
|
||||
"traits": [
|
||||
{ "id": "savage_jaws", "name": "Savage Jaws", "description": "Unarmed bite deals 1d8 + STR piercing. On a critical hit, the wound bleeds: 1d4 damage at the start of the target's turn for 2 turns." },
|
||||
{ "id": "indomitable_ferocity", "name": "Indomitable Ferocity", "description": "When reduced to 0 HP, drop to 1 HP instead. Once per long rest." }
|
||||
{ "id": "wolverine_frenzy", "name": "Wolverine's Frenzy", "description": "Once per long rest, bonus action: enter a frenzy for 1 minute. While frenzied, gain resistance to all damage except psychic. (Distinct from the Feral class's Rage; renamed to avoid confusion. Doc: 'Feral Rage'.)" },
|
||||
{ "id": "jaws_of_iron", "name": "Jaws of Iron", "description": "Bite attack deals 1d8 + STR piercing and can target objects. Chew through rope, leather, and soft wood as an action. Hard materials (metal, stone) take 1 minute per inch." },
|
||||
{ "id": "arctic_survivor", "name": "Arctic Survivor", "description": "Immunity to the effects of extreme cold environments. Leave no tracks in snow. Advantage on Survival checks in tundra, mountain, and arctic terrain." }
|
||||
],
|
||||
"detriments": [
|
||||
{ "id": "feared_kin", "name": "Feared Kin", "description": "Disadvantage on CHA (Persuasion) checks with non-Mustelid creatures who recognize your species. Wolverine reputation precedes you." }
|
||||
{ "id": "berserkers_toll", "name": "Berserker's Toll", "description": "After Wolverine's Frenzy ends, gain one level of exhaustion. The fire burns hot but the crash is real." },
|
||||
{ "id": "feared_not_loved", "name": "Feared, Not Loved", "description": "Disadvantage on CHA (Persuasion) checks with creatures who know what you are. Wolverine-folk's reputation precedes them, and it is not a friendly reputation." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "brown_bear",
|
||||
"clade_id": "ursidae",
|
||||
"name": "Brown Bear-Folk",
|
||||
"description": "\"The standard by which all bears measure themselves, and everyone else measures trouble.\"\n\nEnormous shoulder hump of muscle, broad everywhere — the physically largest common species in the world. Dense, coarse, shaggy fur from honey-blonde to chocolate to grizzled silver-tip. Warm in calm; terrifying when not.",
|
||||
"size": "large",
|
||||
"ability_mods": { "STR": 1 },
|
||||
"base_speed_ft": 30,
|
||||
"traits": [
|
||||
{ "id": "rending_claws", "name": "Rending Claws", "description": "Unarmed claw attacks deal 1d8 + STR slashing. Two-paw rend: if both claw attacks hit the same target in one Attack action, deal an extra 1d6 damage." },
|
||||
{ "id": "winter_hibernation", "name": "Winter Hibernation", "description": "Once per year, enter a deep restorative sleep for 1d4 weeks. On waking, fully heal and remove all levels of exhaustion." }
|
||||
{ "id": "devastating_swipe", "name": "Devastating Swipe", "description": "Unarmed claw attack deals 2d6 + STR slashing. On a hit, you may choose to forgo damage and instead shove the target up to 10 ft. in any direction." },
|
||||
{ "id": "foragers_nose", "name": "Forager's Nose", "description": "Advantage on Survival checks to find food in any natural environment. Identify edible vs. toxic plants and fungi by scent." },
|
||||
{ "id": "stubborn_vitality", "name": "Stubborn Vitality", "description": "Advantage on death saving throws." }
|
||||
],
|
||||
"detriments": []
|
||||
"detriments": [
|
||||
{ "id": "slow_burn", "name": "Slow Burn", "description": "Act last in the first round of any combat where you were not expecting a fight (initiative count 0, regardless of roll). Ursid threat-processing takes a moment to spool up." },
|
||||
{ "id": "accidental_destruction","name": "Accidental Destruction","description": "When you fail a DEX check by 5 or more while interacting with objects or structures not built for your size, you break them. Tools, furniture, delicate mechanisms, occasionally other people's belongings." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "polar_bear",
|
||||
"clade_id": "ursidae",
|
||||
"name": "Polar Bear-Folk",
|
||||
"description": "\"Born in the white silence. Everything about them is patience and stored violence.\"\n\nSlightly leaner than brown bear-folk through the hip, broader through the chest, built for swimming and cold-weather endurance. White to cream-yellow guard hairs over a dense water-resistant undercoat; the skin beneath is black.",
|
||||
"size": "large",
|
||||
"ability_mods": { "WIS": 1 },
|
||||
"base_speed_ft": 30,
|
||||
"traits": [
|
||||
{ "id": "arctic_adaptation", "name": "Arctic Adaptation", "description": "Resistance to cold damage. Immunity to environmental cold effects. Swim speed equal to walking speed." },
|
||||
{ "id": "white_pelt", "name": "White Pelt", "description": "Advantage on Stealth checks in snow, ice, or arctic terrain." }
|
||||
{ "id": "arctic_apex", "name": "Arctic Apex", "description": "Immunity to cold damage and cold environments. Swim speed of 30 ft. Hold your breath for 15 minutes." },
|
||||
{ "id": "patient_hunter", "name": "Patient Hunter", "description": "If you do not move during your turn, your next melee attack before the end of your next turn deals an additional 1d8 damage. Stillness precedes the strike." },
|
||||
{ "id": "insulating_bulk", "name": "Insulating Bulk", "description": "Resistance to non-magical bludgeoning damage. Layers of fat and fur absorb impact." }
|
||||
],
|
||||
"detriments": [
|
||||
{ "id": "polar_appetite", "name": "Polar Appetite", "description": "Requires triple rations daily. Without them, gain a level of exhaustion every 8 hours." }
|
||||
{ "id": "heat_vulnerable", "name": "Heat Vulnerable", "description": "In temperatures above 75°F / 24°C, CON save (DC 12) every hour or gain exhaustion. Above 90°F, the DC increases to 15. Polar bear-folk suffer in warm climates." },
|
||||
{ "id": "resource_hungry", "name": "Resource Hungry", "description": "Requires triple standard rations (caloric needs are massive). Failure to meet this imposes exhaustion at an accelerated rate." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "elk",
|
||||
"clade_id": "cervidae",
|
||||
"name": "Elk-Folk",
|
||||
"description": "\"Herd-builders, wall-builders, civilization-builders. The hooves that stamped order into chaos.\"\n\nTall and long-legged, powerful through the haunches and chest. Tawny brown body, darker neck, pale rump patch. Males grow impressive branching antlers — broad, branching, shed and regrown annually — used in display, defense, and cultural adornment.",
|
||||
"size": "medium_large",
|
||||
"ability_mods": { "STR": 1 },
|
||||
"base_speed_ft": 30,
|
||||
"base_speed_ft": 40,
|
||||
"traits": [
|
||||
{ "id": "majestic_antlers", "name": "Majestic Antlers", "description": "Antler attack deals 1d8 + STR piercing. Charging attack: if you move at least 20 ft. straight before attacking, deal +1d6 damage and target makes a STR save (DC = 8 + prof + STR) or is knocked back 5 ft." }
|
||||
{ "id": "herd_coordination", "name": "Herd Coordination", "description": "When you take the Help action to assist an ally's check or attack, the ally gains a +3 bonus instead of advantage. Cervidae herd instinct made tactical." },
|
||||
{ "id": "endurance_runner", "name": "Endurance Runner", "description": "Base speed 40 ft. Advantage on CON saves against forced march, exhaustion from prolonged movement, and effects that would slow your pace on open ground." }
|
||||
],
|
||||
"detriments": [
|
||||
{ "id": "antler_drag", "name": "Antler Drag", "description": "During antler-shed season (1 month per year), antlers fall off — antler attack damage reduced by 1 die step until they regrow." }
|
||||
{ "id": "herd_instinct", "name": "Herd Instinct", "description": "When an allied creature within 30 ft. takes the Dash action to flee combat, WIS save (DC 12) or use your reaction to Dash in the same direction. The herd moves together — even when only some of it should." }
|
||||
],
|
||||
"variant_axis": "sex",
|
||||
"variants": [
|
||||
{
|
||||
"id": "male",
|
||||
"name": "Bull",
|
||||
"traits": [
|
||||
{ "id": "antler_combat", "name": "Antler Combat", "description": "Antler attack deals 1d8 + STR piercing. On a hit, you may forgo damage to shove the target 5 ft. While the full rack is grown (outside antler-shed season), antler attacks have reach 10 ft." }
|
||||
],
|
||||
"detriments": [
|
||||
{ "id": "antler_drag", "name": "Antler Drag", "description": "During antler-shed season (1 month per year), antlers fall off — antler attack damage reduced by 1 die step and the 10 ft. reach is lost until they regrow." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "female",
|
||||
"name": "Cow",
|
||||
"traits": [
|
||||
{ "id": "kick", "name": "Kick", "description": "Hooved kick attack deals 1d8 + STR bludgeoning. On a critical hit, the target is knocked prone. The herd's other answer to threats." }
|
||||
],
|
||||
"detriments": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "deer",
|
||||
"clade_id": "cervidae",
|
||||
"name": "Deer-Folk",
|
||||
"description": "\"Quiet, cautious, and always closer to gone than you realize.\"\n\nLean, graceful, built entirely for speed and evasion — not frail, just efficient. Reddish-brown in warm seasons, grey-brown in cold; white underbelly with a white tail-flag. Wide-set lateral eyes with peripheral vision approaching 310 degrees.",
|
||||
"size": "medium",
|
||||
"ability_mods": { "DEX": 1 },
|
||||
"base_speed_ft": 35,
|
||||
@@ -208,19 +273,25 @@
|
||||
"id": "moose",
|
||||
"clade_id": "cervidae",
|
||||
"name": "Moose-Folk",
|
||||
"description": "\"The exception to every rule about prey being fragile. Eight feet of 'try it.'\"\n\nMassive — thick-bodied, heavy-shouldered, long-legged — the largest Cervidae species, rivaling Ursidae in sheer mass. Dark brown to black, coarse and shaggy, with a loose dewlap at the throat. Males carry enormous palmate antlers that force doorway accommodations.",
|
||||
"size": "large",
|
||||
"ability_mods": { "CON": 1 },
|
||||
"base_speed_ft": 30,
|
||||
"traits": [
|
||||
{ "id": "broad_antlers", "name": "Broad Antlers", "description": "Antler attack deals 1d10 + STR piercing. Charging attack: if you move at least 20 ft. straight before attacking, target makes a STR save (DC = 8 + prof + STR) or is knocked prone." },
|
||||
{ "id": "swamp_strider", "name": "Swamp Strider", "description": "No movement penalty in marsh, mud, snow, or shallow water." }
|
||||
{ "id": "dont_tread_on_me", "name": "Don't Tread on Me", "description": "Count as one size larger for grappling, shoving, and resisting forced movement. Creatures that hit you with a melee attack within 5 ft. provoke a free kick (1d8 + STR) as a reaction. Uses equal to proficiency bonus per long rest." },
|
||||
{ "id": "palmate_antlers", "name": "Palmate Antlers", "description": "Antler attack deals 2d6 + STR bludgeoning. On a critical hit, the target is stunned until the end of their next turn. These antlers are not decorative." },
|
||||
{ "id": "wetland_wader", "name": "Wetland Wader", "description": "No movement penalty in difficult terrain caused by water, mud, or swamp. Swim speed of 20 ft. Hold your breath for 5 minutes." }
|
||||
],
|
||||
"detriments": []
|
||||
"detriments": [
|
||||
{ "id": "ursid_scale_problems", "name": "Ursid-Scale Problems", "description": "As a Large creature, you share many of the infrastructure problems of Ursidae — standard furniture, doorways, and vehicles are too small. Disadvantage on DEX (Stealth) checks — you are not subtle." },
|
||||
{ "id": "solitary_cervid", "name": "Solitary Cervid", "description": "Unlike most Cervidae, moose-folk are not herd-oriented. You do not benefit from Herd Coordination effects and have disadvantage on CHA checks in groups larger than 4. You don't do committees." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rabbit",
|
||||
"clade_id": "leporidae",
|
||||
"name": "Rabbit-Folk",
|
||||
"description": "\"They built the warren-cities, the message networks, the emergency medical system. Underestimate them. They're counting on it.\"\n\nCompact and soft-featured, with deceptively powerful hindquarters. Enormous coat variety. Long ears (upright or lopping by lineage — culturally significant) and a constantly twitching nose.",
|
||||
"size": "small",
|
||||
"ability_mods": { "WIS": 1 },
|
||||
"base_speed_ft": 40,
|
||||
@@ -236,6 +307,7 @@
|
||||
"id": "hare",
|
||||
"clade_id": "leporidae",
|
||||
"name": "Hare-Folk",
|
||||
"description": "\"Not rabbits. Bigger. Wilder. Born on the surface, not in a burrow. There's a difference, and they will tell you about it.\"\n\nLeaner and rangier than rabbit-folk — longer limbs, longer ears, longer stride. Tawny brown, grey, or seasonally white. Build is more runner than burrower; ears tipped black; gaze sharp and confrontational.",
|
||||
"size": "medium",
|
||||
"ability_mods": { "CON": 1 },
|
||||
"base_speed_ft": 45,
|
||||
@@ -253,6 +325,7 @@
|
||||
"id": "bull",
|
||||
"clade_id": "bovidae",
|
||||
"name": "Bull-Folk",
|
||||
"description": "\"Big. Patient. And when patience runs out, apocalyptic.\"\n\nMassive, thick-necked, broad-shouldered, heavy through chest and belly — not fat, dense; everything about them says immovable. Heavy horns curving outward and up. Hooves instead of paws — hard, split, built for impact.",
|
||||
"size": "large",
|
||||
"ability_mods": { "STR": 1 },
|
||||
"base_speed_ft": 25,
|
||||
@@ -267,16 +340,35 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ram",
|
||||
"id": "sheep",
|
||||
"clade_id": "bovidae",
|
||||
"name": "Ram-Folk",
|
||||
"name": "Sheep-Folk",
|
||||
"description": "\"We climb because the mountain asked us to. We grow wool because the wind asked us to. We come back, again and again, because that's what the herd is for.\"\n\nStocky and compact, lower center of gravity than bull-folk — powerful especially through the hindquarters. Heavy fleece, tightly curled and lanolin-rich, naturally weather-shedding. Spiral or sweeping horns, cloven hooves, horizontal-slit pupils. Sheep-folk built the high pastures and the wool trade that supports half the world's textile economy.",
|
||||
"size": "medium",
|
||||
"ability_mods": { "WIS": 1 },
|
||||
"base_speed_ft": 30,
|
||||
"traits": [
|
||||
{ "id": "mountain_born", "name": "Mountain Born", "description": "Climb speed equal to walking speed. Immune to altitude sickness. Advantage on DEX checks and saves on narrow, unstable, or steep surfaces." },
|
||||
{ "id": "headbutt", "name": "Headbutt", "description": "Horn attack deals 1d10 + STR when using Charge (20-ft. run-up). Target hit must make a CON save (DC = 8 + prof + STR) or be dazed (disadvantage on next attack roll)." },
|
||||
{ "id": "wool_insulation", "name": "Wool Insulation", "description": "Resistance to cold damage. Advantage on saves against cold environments." }
|
||||
{ "id": "wool_insulation", "name": "Wool Insulation", "description": "Resistance to cold damage. Advantage on saves against cold environments. The fleece does what the fleece is for." }
|
||||
],
|
||||
"detriments": [
|
||||
{ "id": "horizontal_pupils", "name": "Horizontal Pupils", "description": "Disadvantage on Perception checks requiring depth perception at distances greater than 60 ft." },
|
||||
{ "id": "herd_mentality", "name": "Herd Mentality", "description": "When 3+ visible allies are moving in a direction, WIS save (DC 10) or feel compelled to move with them." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "goat",
|
||||
"clade_id": "bovidae",
|
||||
"name": "Goat-Folk",
|
||||
"description": "\"Yes, I can stand on that. Yes, I can eat that. Yes, I am going to.\"\n\nLeaner and more angular than sheep-folk, athletic through the shoulder, with the same low center of gravity. Coarse hair instead of wool. Curving horns swept back. Horizontal-slit pupils. Goat-folk thrive where nothing else can — desert mesas, cliff edges, salt marshes, the upper slopes where the air thins and the rocks don't hold.",
|
||||
"size": "medium",
|
||||
"ability_mods": { "WIS": 1 },
|
||||
"base_speed_ft": 30,
|
||||
"traits": [
|
||||
{ "id": "mountain_born", "name": "Mountain Born", "description": "Climb speed equal to walking speed. Immune to altitude sickness. Advantage on DEX checks and saves on narrow, unstable, or steep surfaces." },
|
||||
{ "id": "headbutt", "name": "Headbutt", "description": "Horn attack deals 1d10 + STR when using Charge (20-ft. run-up). Target hit must make a CON save (DC = 8 + prof + STR) or be dazed (disadvantage on next attack roll)." },
|
||||
{ "id": "stubborn_metabolism", "name": "Stubborn Metabolism", "description": "Subsist on half normal rations. Advantage on CON saves against ingested poisons, spoiled food, and unusual environmental contaminants. Goat-line digestive systems are infamous for a reason." }
|
||||
],
|
||||
"detriments": [
|
||||
{ "id": "horizontal_pupils", "name": "Horizontal Pupils", "description": "Disadvantage on Perception checks requiring depth perception at distances greater than 60 ft." },
|
||||
@@ -287,6 +379,7 @@
|
||||
"id": "bison",
|
||||
"clade_id": "bovidae",
|
||||
"name": "Bison-Folk",
|
||||
"description": "\"The prairie made them. Nothing else could.\"\n\nMassive shoulder hump and enormous head, thick through chest and front; hindquarters comparatively lean. Dark brown to near-black, shaggy through head and shoulders. Short, curved, wickedly sharp horns. Everything about the front profile says battering ram.",
|
||||
"size": "large",
|
||||
"ability_mods": { "CON": 1 },
|
||||
"base_speed_ft": 25,
|
||||
|
||||
@@ -16,6 +16,12 @@ public sealed record CladeDef
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Codex-voice clade description: usually the doc's italicized
|
||||
/// blockquote followed by a one-sentence summary. Surfaced on the
|
||||
/// Step 0 card to establish the world's tone before mechanics.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { 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();
|
||||
|
||||
@@ -16,6 +16,12 @@ public sealed record ClassDef
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Codex-voice calling description: in-character quote followed
|
||||
/// by a one-paragraph profile (mirrors CladeDef / SpeciesDef). Surfaced
|
||||
/// on the Step III calling card.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
/// <summary>Hit die size: 6 / 8 / 10 / 12.</summary>
|
||||
[JsonPropertyName("hit_die")]
|
||||
public int HitDie { get; init; } = 8;
|
||||
|
||||
@@ -18,6 +18,12 @@ public sealed record SpeciesDef
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Codex-voice species description: usually the doc's italicized
|
||||
/// blockquote followed by a one-sentence physical/cultural summary.
|
||||
/// Surfaced on the Step 1 card.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = "";
|
||||
|
||||
/// <summary>Body size category, snake_case (small / medium / medium_large / large).</summary>
|
||||
[JsonPropertyName("size")]
|
||||
public string Size { get; init; } = "medium";
|
||||
@@ -35,4 +41,43 @@ public sealed record SpeciesDef
|
||||
|
||||
[JsonPropertyName("detriments")]
|
||||
public TraitDef[] Detriments { get; init; } = Array.Empty<TraitDef>();
|
||||
|
||||
/// <summary>
|
||||
/// "sex" (male/female) or "lineage" (e.g. sheep/goat for Ram-Folk).
|
||||
/// Empty when the species has no variants. Determines how the variant
|
||||
/// is resolved: sex-axis is auto-keyed off character Sex (purebred) or
|
||||
/// parent role for hybrids (sire = male, dam = female); lineage-axis
|
||||
/// requires an explicit per-species pick.
|
||||
/// </summary>
|
||||
[JsonPropertyName("variant_axis")]
|
||||
public string VariantAxis { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Variant entries layered onto the species' base traits/detriments.
|
||||
/// The variant's id keys it: for sex-axis variants, "male" or "female";
|
||||
/// for lineage variants, the lineage tag (e.g. "sheep", "goat").
|
||||
/// Empty when the species has no variants.
|
||||
/// </summary>
|
||||
[JsonPropertyName("variants")]
|
||||
public SpeciesVariantDef[] Variants { get; init; } = Array.Empty<SpeciesVariantDef>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One sex- or lineage-keyed variant of a species. Layered on top of the
|
||||
/// base species' traits and detriments — the player ends up with both
|
||||
/// sets, not one or the other.
|
||||
/// </summary>
|
||||
public sealed record SpeciesVariantDef
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
[JsonPropertyName("traits")]
|
||||
public TraitDef[] Traits { get; init; } = Array.Empty<TraitDef>();
|
||||
|
||||
[JsonPropertyName("detriments")]
|
||||
public TraitDef[] Detriments { get; init; } = Array.Empty<TraitDef>();
|
||||
}
|
||||
|
||||
@@ -65,6 +65,21 @@ public sealed class CharacterBuilder
|
||||
/// </summary>
|
||||
public ParentLineage HybridDominantParent { get; set; } = ParentLineage.Sire;
|
||||
|
||||
/// <summary>
|
||||
/// Sire's chosen ability key ("STR", "DEX", "CON", "INT", "WIS", "CHA").
|
||||
/// Hybrid PCs take ONE ability mod from each parent clade — this records
|
||||
/// which one the player chose from sire's clade. Empty string means no
|
||||
/// bonus from this side (graceful default for headless builds and old
|
||||
/// content). The resulting mod value is whatever the sire clade grants
|
||||
/// for that ability (e.g. picking CON from canidae yields +1, picking
|
||||
/// CON from ursidae yields +2). Stacks additively with the dam pick if
|
||||
/// both happen to land on the same ability.
|
||||
/// </summary>
|
||||
public string HybridSireChosenAbility { get; set; } = "";
|
||||
|
||||
/// <summary>Dam's chosen ability key — see <see cref="HybridSireChosenAbility"/>.</summary>
|
||||
public string HybridDamChosenAbility { get; set; } = "";
|
||||
|
||||
// ── Builder fluent helpers ──────────────────────────────────────────
|
||||
|
||||
public CharacterBuilder WithClade(CladeDef c) { Clade = c; return this; }
|
||||
@@ -282,17 +297,16 @@ public sealed class CharacterBuilder
|
||||
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.
|
||||
// Blend ability mods: take ONE chosen ability mod from each parent
|
||||
// clade — the picks are recorded on HybridSireChosenAbility /
|
||||
// HybridDamChosenAbility (set by the wizard's StepClade lineage
|
||||
// bonus picker, or left empty in headless tests for no bonus).
|
||||
// Picks stack additively if both parents land on the same ability.
|
||||
// Species mods don't apply for hybrids per project decision; the
|
||||
// 2-mod ceiling intentionally caps hybrid bonuses below purebred's.
|
||||
var modded = BaseAbilities;
|
||||
modded = ApplyMods(modded, HybridSireClade!.AbilityMods);
|
||||
modded = ApplyMods(modded, HybridDamClade!.AbilityMods);
|
||||
modded = ApplyMods(modded, HybridSireSpecies!.AbilityMods);
|
||||
modded = ApplyMods(modded, HybridDamSpecies!.AbilityMods);
|
||||
modded = ApplyOneMod(modded, HybridSireChosenAbility, HybridSireClade!.AbilityMods);
|
||||
modded = ApplyOneMod(modded, HybridDamChosenAbility, HybridDamClade!.AbilityMods);
|
||||
|
||||
var c = new Character(dominantClade, dominantSpecies, ClassDef, Background, modded)
|
||||
{
|
||||
@@ -367,6 +381,24 @@ public sealed class CharacterBuilder
|
||||
return a.Plus(dict);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a single mod sourced from <paramref name="modsSource"/> at key
|
||||
/// <paramref name="chosenAbility"/>. No-op when the key is empty (player
|
||||
/// hasn't chosen) or absent from the source (defensive — the wizard's
|
||||
/// picker should only offer abilities present in the clade's mods, but
|
||||
/// we don't want a typo'd save to crash character finalize).
|
||||
/// </summary>
|
||||
private static AbilityScores ApplyOneMod(
|
||||
AbilityScores a, string chosenAbility, IReadOnlyDictionary<string, int> modsSource)
|
||||
{
|
||||
if (string.IsNullOrEmpty(chosenAbility)) return a;
|
||||
if (!TryParseAbility(chosenAbility, out var id)) return a;
|
||||
if (modsSource is null || !modsSource.TryGetValue(chosenAbility, out int value) || value == 0)
|
||||
return a;
|
||||
var dict = new Dictionary<AbilityId, int> { [id] = value };
|
||||
return a.Plus(dict);
|
||||
}
|
||||
|
||||
private static bool TryParseAbility(string raw, out AbilityId id)
|
||||
{
|
||||
switch (raw.ToUpperInvariant())
|
||||
@@ -401,6 +433,19 @@ public sealed class CharacterBuilder
|
||||
SkillId.SleightOfHand => "sleight_of_hand",
|
||||
SkillId.Stealth => "stealth",
|
||||
SkillId.Survival => "survival",
|
||||
|
||||
SkillId.Brawl => "brawl",
|
||||
SkillId.BuildRead => "build_read",
|
||||
SkillId.Driving => "driving",
|
||||
SkillId.Endurance => "endurance",
|
||||
SkillId.Force => "force",
|
||||
SkillId.Fortitude => "fortitude",
|
||||
SkillId.Hardiness => "hardiness",
|
||||
SkillId.Haulage => "haulage",
|
||||
SkillId.LungCraft => "lung_craft",
|
||||
SkillId.Marksmanship => "marksmanship",
|
||||
SkillId.PainTolerance => "pain_tolerance",
|
||||
SkillId.ScentSpeak => "scent_speak",
|
||||
_ => s.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ public enum SizeCategory : byte
|
||||
{
|
||||
Tiny = 0, // reserved; no Phase 5 species uses this
|
||||
Small = 1, // rabbit-folk, housecat-folk, ferret-folk
|
||||
Medium = 2, // fox-folk, deer-folk, ram-folk, leopard-folk, badger-folk, hare-folk
|
||||
Medium = 2, // fox-folk, deer-folk, sheep-folk, goat-folk, leopard-folk, badger-folk, hare-folk
|
||||
MediumLarge = 3, // wolf-folk, elk-folk, lion-folk, wolverine-folk, coyote-folk
|
||||
Large = 4, // brown bear-folk, polar bear-folk, moose-folk, bull-folk, bison-folk
|
||||
Huge = 5, // reserved; no Phase 5 species uses this
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
namespace Theriapolis.Core.Rules.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Standard d20-adjacent skill list. Each skill is backed by a single
|
||||
/// ability — see <see cref="SkillAbility"/>.
|
||||
/// Skill list used by Theriapolis — extends the d20 baseline with 12
|
||||
/// additional skills (M6.18) so each ability has 5 skills tied to it.
|
||||
/// New skills appended at the end of the byte enum to preserve save-game
|
||||
/// compatibility with characters created before M6.18. Each skill is
|
||||
/// backed by a single ability — see <see cref="SkillIdExtensions.Ability"/>.
|
||||
/// </summary>
|
||||
public enum SkillId : byte
|
||||
{
|
||||
@@ -24,6 +27,20 @@ public enum SkillId : byte
|
||||
SleightOfHand = 15,
|
||||
Stealth = 16,
|
||||
Survival = 17,
|
||||
|
||||
// M6.18 — Theriapolis-flavored expansions, 5 per ability.
|
||||
Brawl = 18, // STR
|
||||
BuildRead = 19, // STR
|
||||
Driving = 20, // DEX
|
||||
Endurance = 21, // CON
|
||||
Force = 22, // STR
|
||||
Fortitude = 23, // CON
|
||||
Hardiness = 24, // CON
|
||||
Haulage = 25, // STR
|
||||
LungCraft = 26, // CON
|
||||
Marksmanship = 27, // DEX
|
||||
PainTolerance = 28, // CON
|
||||
ScentSpeak = 29, // CHA
|
||||
}
|
||||
|
||||
public static class SkillIdExtensions
|
||||
@@ -48,6 +65,19 @@ public static class SkillIdExtensions
|
||||
SkillId.SleightOfHand => AbilityId.DEX,
|
||||
SkillId.Stealth => AbilityId.DEX,
|
||||
SkillId.Survival => AbilityId.WIS,
|
||||
|
||||
SkillId.Brawl => AbilityId.STR,
|
||||
SkillId.BuildRead => AbilityId.STR,
|
||||
SkillId.Driving => AbilityId.DEX,
|
||||
SkillId.Endurance => AbilityId.CON,
|
||||
SkillId.Force => AbilityId.STR,
|
||||
SkillId.Fortitude => AbilityId.CON,
|
||||
SkillId.Hardiness => AbilityId.CON,
|
||||
SkillId.Haulage => AbilityId.STR,
|
||||
SkillId.LungCraft => AbilityId.CON,
|
||||
SkillId.Marksmanship => AbilityId.DEX,
|
||||
SkillId.PainTolerance => AbilityId.CON,
|
||||
SkillId.ScentSpeak => AbilityId.CHA,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s)),
|
||||
};
|
||||
|
||||
@@ -72,6 +102,19 @@ public static class SkillIdExtensions
|
||||
"sleight_of_hand" => SkillId.SleightOfHand,
|
||||
"stealth" => SkillId.Stealth,
|
||||
"survival" => SkillId.Survival,
|
||||
|
||||
"brawl" => SkillId.Brawl,
|
||||
"build_read" => SkillId.BuildRead,
|
||||
"driving" => SkillId.Driving,
|
||||
"endurance" => SkillId.Endurance,
|
||||
"force" => SkillId.Force,
|
||||
"fortitude" => SkillId.Fortitude,
|
||||
"hardiness" => SkillId.Hardiness,
|
||||
"haulage" => SkillId.Haulage,
|
||||
"lung_craft" => SkillId.LungCraft,
|
||||
"marksmanship" => SkillId.Marksmanship,
|
||||
"pain_tolerance" => SkillId.PainTolerance,
|
||||
"scent_speak" => SkillId.ScentSpeak,
|
||||
_ => throw new ArgumentException($"Unknown skill: '{raw}'"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,7 +85,14 @@ public partial class Aside : MarginContainer
|
||||
|
||||
private void BuildDetailsGrid()
|
||||
{
|
||||
if (_draft!.IsHybrid)
|
||||
string sexLabel = _draft!.Sex switch
|
||||
{
|
||||
"male" => "Male",
|
||||
"female" => "Female",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
if (_draft.IsHybrid)
|
||||
{
|
||||
// Hybrid layout: SIRE / DAM column headers above the parent
|
||||
// detail rows, then the Calling / Background row spans both
|
||||
@@ -102,6 +109,10 @@ public partial class Aside : MarginContainer
|
||||
lineageGrid.AddChild(MakeCell("Clade", CodexContent.Clade(_draft.DamCladeId)?.Name));
|
||||
lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.SireSpeciesId)?.Name));
|
||||
lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.DamSpeciesId)?.Name));
|
||||
// Sex is a character-level field, not per-parent — span left
|
||||
// column with an empty filler in the right.
|
||||
lineageGrid.AddChild(MakeCell("Sex", sexLabel));
|
||||
lineageGrid.AddChild(new Control());
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -109,6 +120,8 @@ public partial class Aside : MarginContainer
|
||||
_content.AddChild(lineageGrid);
|
||||
lineageGrid.AddChild(MakeCell("Clade", CodexContent.Clade(_draft.CladeId)?.Name));
|
||||
lineageGrid.AddChild(MakeCell("Species", CodexContent.SpeciesById(_draft.SpeciesId)?.Name));
|
||||
lineageGrid.AddChild(MakeCell("Sex", sexLabel));
|
||||
lineageGrid.AddChild(new Control());
|
||||
}
|
||||
|
||||
// Calling + Background — last row of the lineage block, with
|
||||
@@ -269,10 +282,14 @@ public partial class Aside : MarginContainer
|
||||
// (single-pick each, per doc) plus the four universal detriments.
|
||||
if (_draft.IsHybrid)
|
||||
{
|
||||
AddPickedSpeciesPick(flow, CodexContent.SpeciesById(_draft.SireSpeciesId),
|
||||
var sireSp = CodexContent.SpeciesById(_draft.SireSpeciesId);
|
||||
var damSp = CodexContent.SpeciesById(_draft.DamSpeciesId);
|
||||
AddPickedSpeciesPick(flow, sireSp,
|
||||
_draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment);
|
||||
AddPickedSpeciesPick(flow, CodexContent.SpeciesById(_draft.DamSpeciesId),
|
||||
AddPickedSpeciesPick(flow, damSp,
|
||||
_draft.DamChosenSpeciesTrait, _draft.DamChosenSpeciesDetriment);
|
||||
AddVariantContent(flow, sireSp, _draft.ResolveVariantId(sireSp, "sire"));
|
||||
AddVariantContent(flow, damSp, _draft.ResolveVariantId(damSp, "dam"));
|
||||
|
||||
// Universal hybrid detriments — every hybrid has all four.
|
||||
foreach (var (name, desc) in UniversalHybridDetriments)
|
||||
@@ -280,7 +297,9 @@ public partial class Aside : MarginContainer
|
||||
}
|
||||
else
|
||||
{
|
||||
AddSpeciesTraits(flow, CodexContent.SpeciesById(_draft.SpeciesId));
|
||||
var sp = CodexContent.SpeciesById(_draft.SpeciesId);
|
||||
AddSpeciesTraits(flow, sp);
|
||||
AddVariantContent(flow, sp, _draft.ResolveVariantId(sp, ""));
|
||||
}
|
||||
|
||||
// Class level-1 features.
|
||||
@@ -362,6 +381,22 @@ public partial class Aside : MarginContainer
|
||||
flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
||||
}
|
||||
|
||||
/// <summary>Render the resolved variant's extra traits/detriments,
|
||||
/// if any. <paramref name="variantId"/> is the variant key (e.g. "male"
|
||||
/// or "sheep"); empty when no resolution applies.</summary>
|
||||
private static void AddVariantContent(HFlowContainer flow,
|
||||
Theriapolis.Core.Data.SpeciesDef? species,
|
||||
string variantId)
|
||||
{
|
||||
if (species is null || string.IsNullOrEmpty(variantId)) return;
|
||||
var variant = System.Array.Find(species.Variants, v => v.Id == variantId);
|
||||
if (variant is null) return;
|
||||
foreach (var t in variant.Traits)
|
||||
flow.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description });
|
||||
foreach (var d in variant.Detriments)
|
||||
flow.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
||||
}
|
||||
|
||||
/// <summary>Hybrid: one chosen species trait + one chosen species detriment.</summary>
|
||||
private static void AddPickedSpeciesPick(HFlowContainer flow, Theriapolis.Core.Data.SpeciesDef? species,
|
||||
string chosenTraitId, string chosenDetrimentId)
|
||||
|
||||
@@ -43,9 +43,8 @@ public partial class StepBackground : VBoxContainer, IStep
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
});
|
||||
|
||||
_grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
|
||||
_grid.AddThemeConstantOverride("h_separation", 16);
|
||||
_grid.AddThemeConstantOverride("v_separation", 16);
|
||||
_grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
_grid.AddThemeConstantOverride("v_separation", 12);
|
||||
AddChild(_grid);
|
||||
|
||||
Refresh();
|
||||
@@ -67,7 +66,7 @@ public partial class StepBackground : VBoxContainer, IStep
|
||||
bool selected = _draft.BackgroundId == bg.Id;
|
||||
|
||||
var card = CodexCard.Make();
|
||||
card.CustomMinimumSize = new Vector2(200, 0);
|
||||
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
CodexCard.SetSelected(card, selected);
|
||||
|
||||
card.GuiInput += (InputEvent e) =>
|
||||
|
||||
@@ -22,6 +22,8 @@ public partial class StepClade : VBoxContainer, IStep
|
||||
{
|
||||
private CharacterDraft _draft = null!;
|
||||
private Button _hybridToggle = null!;
|
||||
private Button _sexMaleBtn = null!;
|
||||
private Button _sexFemaleBtn = null!;
|
||||
private VBoxContainer _purebredSection = null!;
|
||||
private VBoxContainer _hybridSection = null!;
|
||||
private OptionButton _dominantToggle = null!;
|
||||
@@ -40,8 +42,12 @@ public partial class StepClade : VBoxContainer, IStep
|
||||
private Label _damTraitHeader = null!;
|
||||
|
||||
private readonly Dictionary<string, PanelContainer> _purebredCards = new();
|
||||
private readonly Dictionary<string, PanelContainer> _sireCards = new();
|
||||
private readonly Dictionary<string, PanelContainer> _damCards = new();
|
||||
private readonly Dictionary<string, PanelContainer> _hybridCards = new();
|
||||
// Per-card sire/dam toggle buttons in the hybrid grid header. Mutated
|
||||
// in place via SetPressedNoSignal during Refresh — same Free()-defer
|
||||
// hazard as the lineage bonus rows.
|
||||
private readonly Dictionary<string, Button> _sireToggles = new();
|
||||
private readonly Dictionary<string, Button> _damToggles = new();
|
||||
|
||||
// Bonus rows are mutated in place too: when the clade hasn't changed
|
||||
// since the last build, we only flip ButtonPressed states. Rebuilding
|
||||
@@ -83,6 +89,27 @@ public partial class StepClade : VBoxContainer, IStep
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
});
|
||||
|
||||
// Sex picker — required for every character. For purebreds with
|
||||
// sex-axis variants (Elk, Lion) this drives variant resolution;
|
||||
// for hybrids it's identity-only since sire/dam are male/female
|
||||
// by parent-role definition.
|
||||
var sexRow = new HBoxContainer();
|
||||
sexRow.AddThemeConstantOverride("separation", 8);
|
||||
AddChild(sexRow);
|
||||
sexRow.AddChild(new Label { Text = "SEX", ThemeTypeVariation = "Eyebrow" });
|
||||
_sexMaleBtn = new Button
|
||||
{
|
||||
Text = "Male", ToggleMode = true, FocusMode = Control.FocusModeEnum.None,
|
||||
};
|
||||
_sexFemaleBtn = new Button
|
||||
{
|
||||
Text = "Female", ToggleMode = true, FocusMode = Control.FocusModeEnum.None,
|
||||
};
|
||||
_sexMaleBtn.Pressed += () => OnSexPicked("male");
|
||||
_sexFemaleBtn.Pressed += () => OnSexPicked("female");
|
||||
sexRow.AddChild(_sexMaleBtn);
|
||||
sexRow.AddChild(_sexFemaleBtn);
|
||||
|
||||
// Toggle Button (not CheckBox) so the inverted-on-press button style
|
||||
// from the codex theme handles selection visually — no checkbox glyph
|
||||
// needed, the bg colour shift is the affordance.
|
||||
@@ -111,15 +138,24 @@ public partial class StepClade : VBoxContainer, IStep
|
||||
_hybridSection.AddThemeConstantOverride("separation", 16);
|
||||
AddChild(_hybridSection);
|
||||
|
||||
_hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage", ThemeTypeVariation = "Eyebrow" });
|
||||
var sireGrid = MakeGrid();
|
||||
_hybridSection.AddChild(sireGrid);
|
||||
PopulateGrid(sireGrid, _sireCards, id => OnLineageCladePicked("sire", id));
|
||||
|
||||
_hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage", ThemeTypeVariation = "Eyebrow" });
|
||||
var damGrid = MakeGrid();
|
||||
_hybridSection.AddChild(damGrid);
|
||||
PopulateGrid(damGrid, _damCards, id => OnLineageCladePicked("dam", id));
|
||||
// One unified hybrid grid: every clade card carries Sire/Dam toggle
|
||||
// buttons in its header. The same card can become either parent;
|
||||
// picking Sire on a card currently set as Dam clears the Dam pick
|
||||
// (and vice versa) atomically.
|
||||
_hybridSection.AddChild(new Label
|
||||
{
|
||||
Text = "Mark one clade as Sire (paternal) and another as Dam (maternal). "
|
||||
+ "A single clade cannot be both.",
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
});
|
||||
var hybridGrid = MakeGrid();
|
||||
_hybridSection.AddChild(hybridGrid);
|
||||
foreach (var clade in CodexContent.Clades)
|
||||
{
|
||||
var card = BuildHybridCard(clade);
|
||||
_hybridCards[clade.Id] = card;
|
||||
hybridGrid.AddChild(card);
|
||||
}
|
||||
|
||||
// Lineage bonus pickers — hybrids pick ONE ability mod from each
|
||||
// parent clade. Stacking on the same ability is allowed; mods sum.
|
||||
@@ -181,9 +217,11 @@ public partial class StepClade : VBoxContainer, IStep
|
||||
|
||||
private static GridContainer MakeGrid()
|
||||
{
|
||||
var grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
|
||||
grid.AddThemeConstantOverride("h_separation", 16);
|
||||
grid.AddThemeConstantOverride("v_separation", 16);
|
||||
// Single-column layout: each card spans the wizard's content width
|
||||
// and surfaces the clade's description text. Establishes the
|
||||
// world's tone before mechanics — the trade is more scrolling.
|
||||
var grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
grid.AddThemeConstantOverride("v_separation", 12);
|
||||
return grid;
|
||||
}
|
||||
|
||||
@@ -224,6 +262,9 @@ public partial class StepClade : VBoxContainer, IStep
|
||||
_draft.Patch(patch);
|
||||
}
|
||||
|
||||
private void OnSexPicked(string sex) =>
|
||||
_draft.Patch(new Godot.Collections.Dictionary { { "sex", sex } });
|
||||
|
||||
private void OnDominantSelected(long index)
|
||||
{
|
||||
string newDominant = index == 0 ? "sire" : "dam";
|
||||
@@ -259,9 +300,13 @@ public partial class StepClade : VBoxContainer, IStep
|
||||
_purebredSection.Visible = !hybrid;
|
||||
_hybridSection.Visible = hybrid;
|
||||
|
||||
bool isMale = _draft.Sex == "male";
|
||||
bool isFemale = _draft.Sex == "female";
|
||||
if (_sexMaleBtn.ButtonPressed != isMale) _sexMaleBtn.SetPressedNoSignal(isMale);
|
||||
if (_sexFemaleBtn.ButtonPressed != isFemale) _sexFemaleBtn.SetPressedNoSignal(isFemale);
|
||||
|
||||
UpdateSelection(_purebredCards, _draft.CladeId);
|
||||
UpdateSelection(_sireCards, _draft.SireCladeId);
|
||||
UpdateSelection(_damCards, _draft.DamCladeId);
|
||||
UpdateHybridCards();
|
||||
|
||||
int dominantIdx = _draft.DominantParent == "dam" ? 1 : 0;
|
||||
if (_dominantToggle.Selected != dominantIdx) _dominantToggle.Select(dominantIdx);
|
||||
@@ -349,7 +394,14 @@ public partial class StepClade : VBoxContainer, IStep
|
||||
_draft.Patch(patch);
|
||||
}
|
||||
|
||||
private void OnLineageCladePicked(string lineage, string cladeId)
|
||||
/// <summary>
|
||||
/// Build the patch dict that records "<paramref name="lineage"/>'s
|
||||
/// clade is now <paramref name="cladeId"/>" — clearing the dependent
|
||||
/// fields (species, lineage bonus, clade traits) so they don't carry
|
||||
/// stale picks from the prior clade. Returned without applying so the
|
||||
/// caller can merge multiple lineage patches into one atomic Patch.
|
||||
/// </summary>
|
||||
private Godot.Collections.Dictionary BuildLineageCladePatch(string lineage, string cladeId)
|
||||
{
|
||||
var patch = new Godot.Collections.Dictionary
|
||||
{
|
||||
@@ -363,17 +415,57 @@ public partial class StepClade : VBoxContainer, IStep
|
||||
patch[lineage + "_chosen_species_trait"] = "";
|
||||
patch[lineage + "_chosen_species_detriment"] = "";
|
||||
}
|
||||
|
||||
// Clade swap invalidates the previously-picked lineage bonus
|
||||
// (different clade has different mod options) and the clade
|
||||
// trait picks.
|
||||
patch[lineage + "_chosen_ability"] = "";
|
||||
patch[lineage + "_chosen_clade_traits"] = new Godot.Collections.Array<string>();
|
||||
return patch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User toggled Sire or Dam on a hybrid card. If the toggle was just
|
||||
/// turned ON, record the pick (and clear the OTHER lineage if it was
|
||||
/// already pointing at this same clade — a clade can't be both
|
||||
/// parents). If turned OFF, clear that lineage.
|
||||
/// </summary>
|
||||
private void OnHybridParentPressed(string lineage, string cladeId)
|
||||
{
|
||||
string currentForLineage = lineage == "sire" ? _draft.SireCladeId : _draft.DamCladeId;
|
||||
bool wasOn = currentForLineage == cladeId;
|
||||
string newCladeId = wasOn ? "" : cladeId;
|
||||
|
||||
var patch = BuildLineageCladePatch(lineage, newCladeId);
|
||||
|
||||
// If the new pick collides with the OTHER parent, clear it.
|
||||
if (!string.IsNullOrEmpty(newCladeId))
|
||||
{
|
||||
string otherLineage = lineage == "sire" ? "dam" : "sire";
|
||||
string otherCladeId = lineage == "sire" ? _draft.DamCladeId : _draft.SireCladeId;
|
||||
if (otherCladeId == newCladeId)
|
||||
{
|
||||
var otherPatch = BuildLineageCladePatch(otherLineage, "");
|
||||
foreach (var key in otherPatch.Keys) patch[(string)key] = otherPatch[key];
|
||||
}
|
||||
}
|
||||
|
||||
ClearBackgroundIfInvalidated(patch);
|
||||
_draft.Patch(patch);
|
||||
}
|
||||
|
||||
private void UpdateHybridCards()
|
||||
{
|
||||
foreach (var (id, card) in _hybridCards)
|
||||
{
|
||||
bool isSire = id == _draft.SireCladeId;
|
||||
bool isDam = id == _draft.DamCladeId;
|
||||
// The card itself gets a "selected" highlight when either
|
||||
// toggle is on, so it pops visually from the unmarked clades.
|
||||
CodexCard.SetSelected(card, isSire || isDam);
|
||||
if (_sireToggles.TryGetValue(id, out var sireBtn) && sireBtn.ButtonPressed != isSire)
|
||||
sireBtn.SetPressedNoSignal(isSire);
|
||||
if (_damToggles.TryGetValue(id, out var damBtn) && damBtn.ButtonPressed != isDam)
|
||||
damBtn.SetPressedNoSignal(isDam);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLineageBonusPicked(string lineage, string ability)
|
||||
{
|
||||
_draft.Patch(new Godot.Collections.Dictionary
|
||||
@@ -499,7 +591,7 @@ public partial class StepClade : VBoxContainer, IStep
|
||||
private PanelContainer BuildCard(CladeDef clade, System.Action<string> onClick)
|
||||
{
|
||||
var card = CodexCard.Make();
|
||||
card.CustomMinimumSize = new Vector2(200, 0);
|
||||
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
card.GuiInput += (InputEvent e) =>
|
||||
{
|
||||
if (e is InputEventMouseButton mb && mb.Pressed && mb.ButtonIndex == MouseButton.Left)
|
||||
@@ -513,6 +605,106 @@ public partial class StepClade : VBoxContainer, IStep
|
||||
box.AddChild(new Label { Text = clade.Name, ThemeTypeVariation = "CardName" });
|
||||
box.AddChild(new Label { Text = clade.Kind.ToUpperInvariant(), ThemeTypeVariation = "CardMeta" });
|
||||
|
||||
if (!string.IsNullOrEmpty(clade.Description))
|
||||
{
|
||||
box.AddChild(new Label
|
||||
{
|
||||
Text = clade.Description,
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
MouseFilter = Control.MouseFilterEnum.Pass,
|
||||
});
|
||||
}
|
||||
|
||||
if (clade.AbilityMods.Count > 0)
|
||||
{
|
||||
var modsRow = new HBoxContainer();
|
||||
modsRow.AddThemeConstantOverride("separation", 8);
|
||||
box.AddChild(modsRow);
|
||||
foreach (var (k, v) in clade.AbilityMods)
|
||||
modsRow.AddChild(new Label { Text = $"{k} {(v >= 0 ? "+" : "")}{v}" });
|
||||
}
|
||||
|
||||
if (clade.Traits.Length > 0 || clade.Detriments.Length > 0)
|
||||
{
|
||||
var chips = new HFlowContainer { MouseFilter = MouseFilterEnum.Pass };
|
||||
chips.AddThemeConstantOverride("h_separation", 6);
|
||||
chips.AddThemeConstantOverride("v_separation", 4);
|
||||
box.AddChild(chips);
|
||||
foreach (var t in clade.Traits)
|
||||
chips.AddChild(new TraitChip { TraitName = t.Name, Description = t.Description });
|
||||
foreach (var d in clade.Detriments)
|
||||
chips.AddChild(new TraitChip { TraitName = d.Name, Description = d.Description, Detriment = true });
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid-mode clade card. Same body as BuildCard but with Sire and
|
||||
/// Dam toggle buttons stacked at the right edge of the title row, so a
|
||||
/// single grid replaces the old two-stacked-grid layout. Body click is
|
||||
/// disabled — only the toggles change selection.
|
||||
/// </summary>
|
||||
private PanelContainer BuildHybridCard(CladeDef clade)
|
||||
{
|
||||
var card = CodexCard.Make();
|
||||
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
|
||||
var box = new VBoxContainer { MouseFilter = MouseFilterEnum.Pass };
|
||||
box.AddThemeConstantOverride("separation", 6);
|
||||
card.AddChild(box);
|
||||
|
||||
// Header HBox: title VBox (expand fill) + Sire/Dam toggle column.
|
||||
var header = new HBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
|
||||
header.AddThemeConstantOverride("separation", 12);
|
||||
box.AddChild(header);
|
||||
|
||||
var titleCol = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
|
||||
titleCol.AddThemeConstantOverride("separation", 2);
|
||||
header.AddChild(titleCol);
|
||||
titleCol.AddChild(new Label { Text = clade.Name, ThemeTypeVariation = "CardName" });
|
||||
titleCol.AddChild(new Label { Text = clade.Kind.ToUpperInvariant(), ThemeTypeVariation = "CardMeta" });
|
||||
|
||||
var toggleCol = new HBoxContainer
|
||||
{
|
||||
SizeFlagsVertical = Control.SizeFlags.ShrinkBegin,
|
||||
};
|
||||
toggleCol.AddThemeConstantOverride("separation", 6);
|
||||
header.AddChild(toggleCol);
|
||||
|
||||
string capturedId = clade.Id;
|
||||
var sireBtn = new Button
|
||||
{
|
||||
Text = "Sire",
|
||||
ToggleMode = true,
|
||||
FocusMode = Control.FocusModeEnum.None,
|
||||
CustomMinimumSize = new Vector2(64, 0),
|
||||
};
|
||||
sireBtn.Pressed += () => OnHybridParentPressed("sire", capturedId);
|
||||
toggleCol.AddChild(sireBtn);
|
||||
_sireToggles[clade.Id] = sireBtn;
|
||||
|
||||
var damBtn = new Button
|
||||
{
|
||||
Text = "Dam",
|
||||
ToggleMode = true,
|
||||
FocusMode = Control.FocusModeEnum.None,
|
||||
CustomMinimumSize = new Vector2(64, 0),
|
||||
};
|
||||
damBtn.Pressed += () => OnHybridParentPressed("dam", capturedId);
|
||||
toggleCol.AddChild(damBtn);
|
||||
_damToggles[clade.Id] = damBtn;
|
||||
|
||||
if (!string.IsNullOrEmpty(clade.Description))
|
||||
{
|
||||
box.AddChild(new Label
|
||||
{
|
||||
Text = clade.Description,
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
MouseFilter = Control.MouseFilterEnum.Pass,
|
||||
});
|
||||
}
|
||||
|
||||
if (clade.AbilityMods.Count > 0)
|
||||
{
|
||||
var modsRow = new HBoxContainer();
|
||||
|
||||
@@ -47,9 +47,11 @@ public partial class StepClass : VBoxContainer, IStep
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
});
|
||||
|
||||
_grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
|
||||
_grid.AddThemeConstantOverride("h_separation", 16);
|
||||
_grid.AddThemeConstantOverride("v_separation", 16);
|
||||
// Single-column layout matches StepClade / StepSpecies — each card
|
||||
// spans the wizard's content width so the description text fits
|
||||
// comfortably and the calling's tone lands before the mechanics.
|
||||
_grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
_grid.AddThemeConstantOverride("v_separation", 12);
|
||||
AddChild(_grid);
|
||||
|
||||
Refresh();
|
||||
@@ -68,7 +70,7 @@ public partial class StepClass : VBoxContainer, IStep
|
||||
bool selected = _draft.ClassId == cls.Id;
|
||||
|
||||
var card = CodexCard.Make();
|
||||
card.CustomMinimumSize = new Vector2(200, 0);
|
||||
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
CodexCard.SetSelected(card, selected);
|
||||
|
||||
card.GuiInput += (InputEvent e) =>
|
||||
@@ -97,6 +99,16 @@ public partial class StepClass : VBoxContainer, IStep
|
||||
ThemeTypeVariation = "CardMeta",
|
||||
});
|
||||
|
||||
if (!string.IsNullOrEmpty(cls.Description))
|
||||
{
|
||||
box.AddChild(new Label
|
||||
{
|
||||
Text = cls.Description,
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
MouseFilter = Control.MouseFilterEnum.Pass,
|
||||
});
|
||||
}
|
||||
|
||||
if (cls.Saves.Length > 0)
|
||||
{
|
||||
var savesRow = new HBoxContainer();
|
||||
|
||||
@@ -127,15 +127,27 @@ public partial class StepReview : VBoxContainer, IStep
|
||||
{
|
||||
if (WizardValidation.FirstIncomplete(_draft) != -1) return;
|
||||
|
||||
// Persist the draft so a future load path can pick it up.
|
||||
const string SavePath = "user://character.tres";
|
||||
var err = ResourceSaver.Save(_draft, SavePath);
|
||||
if (err != Error.Ok)
|
||||
GD.PushWarning($"[review] ResourceSaver.Save failed: {err}");
|
||||
// Persist the draft so a future load path can resume editing.
|
||||
const string DraftPath = "user://character.tres";
|
||||
var saveErr = ResourceSaver.Save(_draft, DraftPath);
|
||||
if (saveErr != Error.Ok)
|
||||
GD.PushWarning($"[review] ResourceSaver.Save failed: {saveErr}");
|
||||
else
|
||||
GD.Print($"[review] Saved character draft to {SavePath}");
|
||||
GD.Print($"[review] Saved character draft to {DraftPath}");
|
||||
|
||||
GD.Print($"[review] Confirmed: {Summarise(_draft)}");
|
||||
// The actual handoff: build the runtime Character via Core's
|
||||
// CharacterBuilder. Failure here is a content/wiring bug, not a
|
||||
// user error — the wizard's validation should have caught everything
|
||||
// the builder rejects. Surface the message and stay on this step.
|
||||
if (!CharacterAssembler.TryBuild(_draft, out var built, out string buildError))
|
||||
{
|
||||
GD.PushError($"[review] Character build failed: {buildError}");
|
||||
_confirmStatus.Text = $"Could not finalize character — {buildError}";
|
||||
return;
|
||||
}
|
||||
|
||||
GD.Print($"[review] Confirmed: {Summarise(_draft)} → HP={built!.MaxHp}, "
|
||||
+ $"hybrid={built.Hybrid is not null}, skills={built.SkillProficiencies.Count}");
|
||||
|
||||
EmitSignal(SignalName.CharacterConfirmed, _draft);
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ namespace Theriapolis.GodotHost.Scenes.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Step II — Species. Direct port of <c>StepSpecies</c> in
|
||||
/// <c>src/steps.jsx</c> plus the Phase 6.5 hybrid extension: when
|
||||
/// <see cref="CharacterDraft.IsHybrid"/> is true the step shows two
|
||||
/// stacked grids, one filtered by SireCladeId and one by DamCladeId.
|
||||
/// <c>src/steps.jsx</c> plus the Phase 6.5 hybrid extension. Hybrid mode
|
||||
/// uses a single unified grid (M6.16) — sire and dam species lists are
|
||||
/// merged, and each card carries either a Sire or Dam toggle in its
|
||||
/// header matching the clade it belongs to (sire/dam clades are
|
||||
/// guaranteed disjoint by StepClade's parent-conflict rule).
|
||||
/// </summary>
|
||||
public partial class StepSpecies : VBoxContainer, IStep
|
||||
{
|
||||
@@ -17,8 +19,7 @@ public partial class StepSpecies : VBoxContainer, IStep
|
||||
private VBoxContainer _purebredSection = null!;
|
||||
private VBoxContainer _hybridSection = null!;
|
||||
private GridContainer _purebredGrid = null!;
|
||||
private GridContainer _sireGrid = null!;
|
||||
private GridContainer _damGrid = null!;
|
||||
private GridContainer _hybridGrid = null!;
|
||||
|
||||
// Phase B species trait + detriment pickers — single-pick per lineage.
|
||||
private VBoxContainer _pickSection = null!;
|
||||
@@ -66,13 +67,14 @@ public partial class StepSpecies : VBoxContainer, IStep
|
||||
_hybridSection.AddThemeConstantOverride("separation", 16);
|
||||
AddChild(_hybridSection);
|
||||
|
||||
_hybridSection.AddChild(new Label { Text = "SIRE — Paternal Lineage", ThemeTypeVariation = "Eyebrow" });
|
||||
_sireGrid = MakeGrid();
|
||||
_hybridSection.AddChild(_sireGrid);
|
||||
|
||||
_hybridSection.AddChild(new Label { Text = "DAM — Maternal Lineage", ThemeTypeVariation = "Eyebrow" });
|
||||
_damGrid = MakeGrid();
|
||||
_hybridSection.AddChild(_damGrid);
|
||||
_hybridSection.AddChild(new Label
|
||||
{
|
||||
Text = "Pick one species per parent lineage. Sire's clade species "
|
||||
+ "are listed first, then Dam's — click any card to pick it.",
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
});
|
||||
_hybridGrid = MakeGrid();
|
||||
_hybridSection.AddChild(_hybridGrid);
|
||||
|
||||
// Phase B species pickers: one trait + one detriment per parent.
|
||||
_pickSection = new VBoxContainer();
|
||||
@@ -103,9 +105,10 @@ public partial class StepSpecies : VBoxContainer, IStep
|
||||
|
||||
private static GridContainer MakeGrid()
|
||||
{
|
||||
var grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
|
||||
grid.AddThemeConstantOverride("h_separation", 16);
|
||||
grid.AddThemeConstantOverride("v_separation", 16);
|
||||
// Single-column layout: each card spans the wizard's content width
|
||||
// and surfaces the species' description text. Matches StepClade.
|
||||
var grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
grid.AddThemeConstantOverride("v_separation", 12);
|
||||
return grid;
|
||||
}
|
||||
|
||||
@@ -118,10 +121,7 @@ public partial class StepSpecies : VBoxContainer, IStep
|
||||
|
||||
if (hybrid)
|
||||
{
|
||||
RefreshGrid(_sireGrid, _draft.SireCladeId, _draft.SireSpeciesId,
|
||||
spId => OnLineageSpeciesPicked("sire", spId));
|
||||
RefreshGrid(_damGrid, _draft.DamCladeId, _draft.DamSpeciesId,
|
||||
spId => OnLineageSpeciesPicked("dam", spId));
|
||||
RefreshHybridGrid();
|
||||
|
||||
SyncSpeciesPicks(_sirePickCol, ref _sirePicksBuiltFor, "sire",
|
||||
_draft.SireSpeciesId, _draft.SireChosenSpeciesTrait, _draft.SireChosenSpeciesDetriment);
|
||||
@@ -130,27 +130,25 @@ public partial class StepSpecies : VBoxContainer, IStep
|
||||
}
|
||||
else
|
||||
{
|
||||
RefreshGrid(_purebredGrid, _draft.CladeId, _draft.SpeciesId,
|
||||
spId => _draft.Patch(new Godot.Collections.Dictionary { { "species_id", spId } }));
|
||||
RefreshPurebredGrid();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLineageSpeciesPicked(string lineage, string speciesId)
|
||||
{
|
||||
private void OnPurebredSpeciesPicked(string speciesId) =>
|
||||
_draft.Patch(new Godot.Collections.Dictionary { { "species_id", speciesId } });
|
||||
|
||||
private void OnLineageSpeciesPicked(string lineage, string speciesId) =>
|
||||
_draft.Patch(new Godot.Collections.Dictionary
|
||||
{
|
||||
{ lineage + "_species_id", speciesId },
|
||||
// Species swap invalidates the previously-picked species trait/detriment.
|
||||
// Species swap invalidates the previously-picked trait/detriment.
|
||||
{ lineage + "_chosen_species_trait", "" },
|
||||
{ lineage + "_chosen_species_detriment", "" },
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutate-in-place sync for the species-pick column (one trait button
|
||||
/// group + one detriment button group, radio-style). Same Free()-defer
|
||||
/// hazard as the bonus rows in StepClade — only rebuild when the
|
||||
/// species id changes.
|
||||
/// group + one detriment button group, radio-style).
|
||||
/// </summary>
|
||||
private void SyncSpeciesPicks(VBoxContainer col, ref string builtFor,
|
||||
string lineage, string speciesId,
|
||||
@@ -255,18 +253,47 @@ public partial class StepSpecies : VBoxContainer, IStep
|
||||
_draft.Patch(new Godot.Collections.Dictionary { { field, traitId } });
|
||||
}
|
||||
|
||||
private static void RefreshGrid(GridContainer grid, string cladeId, string selectedSpecies, System.Action<string> onClick)
|
||||
private void RefreshPurebredGrid()
|
||||
{
|
||||
foreach (var c in _purebredGrid.GetChildren()) c.Free();
|
||||
if (string.IsNullOrEmpty(_draft.CladeId)) return;
|
||||
foreach (var sp in CodexContent.SpeciesOfClade(_draft.CladeId))
|
||||
_purebredGrid.AddChild(BuildCard(sp, sp.Id == _draft.SpeciesId,
|
||||
spId => OnPurebredSpeciesPicked(spId)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid mode: rebuild the unified grid with sire-clade species
|
||||
/// followed by dam-clade species. Sire and dam clades are
|
||||
/// guaranteed distinct by StepClade's parent-conflict rule, so the
|
||||
/// species lists are disjoint — each card unambiguously belongs to
|
||||
/// one lineage. Full rebuild on every Refresh is safe because Bind
|
||||
/// installs Refresh as a deferred callback.
|
||||
/// </summary>
|
||||
private void RefreshHybridGrid()
|
||||
{
|
||||
foreach (var c in _hybridGrid.GetChildren()) c.Free();
|
||||
AddHybridLineageBlock("sire", _draft.SireCladeId, _draft.SireSpeciesId);
|
||||
AddHybridLineageBlock("dam", _draft.DamCladeId, _draft.DamSpeciesId);
|
||||
}
|
||||
|
||||
private void AddHybridLineageBlock(string lineage, string cladeId, string selectedSpeciesId)
|
||||
{
|
||||
foreach (var c in grid.GetChildren()) c.Free();
|
||||
if (string.IsNullOrEmpty(cladeId)) return;
|
||||
var clade = CodexContent.Clade(cladeId);
|
||||
string headerLabel = (lineage == "sire" ? "SIRE" : "DAM")
|
||||
+ (clade is null ? "" : " — " + clade.Name.ToUpperInvariant());
|
||||
_hybridGrid.AddChild(new Label { Text = headerLabel, ThemeTypeVariation = "Eyebrow" });
|
||||
|
||||
foreach (var sp in CodexContent.SpeciesOfClade(cladeId))
|
||||
grid.AddChild(BuildCard(sp, sp.Id == selectedSpecies, onClick));
|
||||
_hybridGrid.AddChild(BuildCard(sp, sp.Id == selectedSpeciesId,
|
||||
spId => OnLineageSpeciesPicked(lineage, spId)));
|
||||
}
|
||||
|
||||
private static Control BuildCard(SpeciesDef sp, bool selected, System.Action<string> onClick)
|
||||
{
|
||||
var card = CodexCard.Make();
|
||||
card.CustomMinimumSize = new Vector2(200, 0);
|
||||
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
CodexCard.SetSelected(card, selected);
|
||||
|
||||
card.GuiInput += (InputEvent e) =>
|
||||
@@ -286,6 +313,16 @@ public partial class StepSpecies : VBoxContainer, IStep
|
||||
ThemeTypeVariation = "CardMeta",
|
||||
});
|
||||
|
||||
if (!string.IsNullOrEmpty(sp.Description))
|
||||
{
|
||||
box.AddChild(new Label
|
||||
{
|
||||
Text = sp.Description,
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
MouseFilter = Control.MouseFilterEnum.Pass,
|
||||
});
|
||||
}
|
||||
|
||||
if (sp.AbilityMods.Count > 0)
|
||||
{
|
||||
var modsRow = new HBoxContainer();
|
||||
|
||||
@@ -50,9 +50,8 @@ public partial class StepSubclass : VBoxContainer, IStep
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
});
|
||||
|
||||
_grid = new GridContainer { Columns = 3, SizeFlagsHorizontal = SizeFlags.ShrinkCenter };
|
||||
_grid.AddThemeConstantOverride("h_separation", 16);
|
||||
_grid.AddThemeConstantOverride("v_separation", 16);
|
||||
_grid = new GridContainer { Columns = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
_grid.AddThemeConstantOverride("v_separation", 12);
|
||||
AddChild(_grid);
|
||||
|
||||
Refresh();
|
||||
@@ -78,7 +77,7 @@ public partial class StepSubclass : VBoxContainer, IStep
|
||||
bool selected = _draft.SubclassId == sub.Id;
|
||||
|
||||
var card = CodexCard.Make();
|
||||
card.CustomMinimumSize = new Vector2(200, 0);
|
||||
card.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
|
||||
CodexCard.SetSelected(card, selected);
|
||||
|
||||
card.GuiInput += (InputEvent e) =>
|
||||
|
||||
@@ -46,18 +46,33 @@ public partial class PopoverLayer : CanvasLayer
|
||||
{
|
||||
Layer = 100;
|
||||
BuildPopover();
|
||||
// Theme inheritance walks Control descendants only — CanvasLayer is
|
||||
// a plain Node, so it breaks the propagation chain from the Wizard
|
||||
// Control above. _Ready is bottom-up, so the parent Wizard hasn't
|
||||
// assigned its codex theme yet — defer the lookup until parent's
|
||||
// _Ready has run, then pull its theme onto the popup directly.
|
||||
CallDeferred(MethodName.InheritParentTheme);
|
||||
}
|
||||
|
||||
private void InheritParentTheme()
|
||||
{
|
||||
if (GetParent() is Control parentControl && parentControl.Theme is not null)
|
||||
_popup.Theme = parentControl.Theme;
|
||||
}
|
||||
|
||||
private void BuildPopover()
|
||||
{
|
||||
// Ignore so clicks/scroll/hover all pass through to whatever's
|
||||
// beneath. The popover is purely a visual readout; the chip
|
||||
// owns the lifecycle entirely.
|
||||
// owns the lifecycle entirely. ThemeTypeVariation pulls the
|
||||
// CodexPopover stylebox (parchment bg2 + gild border + rounded
|
||||
// corners + soft shadow) defined in CodexTheme.
|
||||
_popup = new PanelContainer
|
||||
{
|
||||
Visible = false,
|
||||
MouseFilter = Control.MouseFilterEnum.Ignore,
|
||||
ZIndex = 100,
|
||||
ThemeTypeVariation = "CodexPopover",
|
||||
};
|
||||
AddChild(_popup);
|
||||
|
||||
@@ -66,19 +81,29 @@ public partial class PopoverLayer : CanvasLayer
|
||||
_popup.AddChild(v);
|
||||
|
||||
var nameRow = new HBoxContainer();
|
||||
nameRow.AddThemeConstantOverride("separation", 8);
|
||||
nameRow.AddThemeConstantOverride("separation", 10);
|
||||
v.AddChild(nameRow);
|
||||
|
||||
_titleLabel = new Label();
|
||||
// Display-serif title at H3 size (20px) — pulls the trait name
|
||||
// out of the body copy below.
|
||||
_titleLabel = new Label { ThemeTypeVariation = "H3" };
|
||||
nameRow.AddChild(_titleLabel);
|
||||
|
||||
_tagLabel = new Label { Visible = false };
|
||||
// Mono uppercase tag label, vertically centred against the title.
|
||||
_tagLabel = new Label
|
||||
{
|
||||
Visible = false,
|
||||
ThemeTypeVariation = "Eyebrow",
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
SizeFlagsVertical = Control.SizeFlags.ShrinkCenter,
|
||||
};
|
||||
nameRow.AddChild(_tagLabel);
|
||||
|
||||
_descLabel = new Label
|
||||
{
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
CustomMinimumSize = new Vector2(220, 0),
|
||||
ThemeTypeVariation = "CardBody",
|
||||
};
|
||||
v.AddChild(_descLabel);
|
||||
}
|
||||
@@ -91,10 +116,18 @@ public partial class PopoverLayer : CanvasLayer
|
||||
_tagLabel.Text = !string.IsNullOrEmpty(tag) ? tag.ToUpperInvariant()
|
||||
: (detriment ? "DETRIMENT" : "");
|
||||
|
||||
// M6.3 default-theme tint: detriment popover gets a red modulate so
|
||||
// it reads visually distinct from a regular trait. The proper
|
||||
// codex StyleBox swap lands in the theming pass.
|
||||
_popup.Modulate = detriment ? new Color(1f, 0.78f, 0.78f) : Colors.White;
|
||||
// Detriment popover swaps to the seal-bordered stylebox via
|
||||
// override; non-detriment clears the override so the default
|
||||
// CodexPopover panel takes effect again.
|
||||
if (detriment && _popup.HasThemeStylebox("panel_detriment", "CodexPopover"))
|
||||
{
|
||||
var box = _popup.GetThemeStylebox("panel_detriment", "CodexPopover");
|
||||
_popup.AddThemeStyleboxOverride("panel", box);
|
||||
}
|
||||
else
|
||||
{
|
||||
_popup.RemoveThemeStyleboxOverride("panel");
|
||||
}
|
||||
|
||||
_popup.Visible = true;
|
||||
_popup.ResetSize();
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Godot;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Items;
|
||||
using Theriapolis.Core.Persistence;
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Rules.Stats;
|
||||
using Theriapolis.GodotHost.Platform;
|
||||
|
||||
namespace Theriapolis.GodotHost.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges the wizard's <see cref="CharacterDraft"/> (Godot Resource) into a
|
||||
/// runtime <see cref="Character"/> via <see cref="CharacterBuilder"/>. Used by
|
||||
/// StepReview's "Confirm & Begin" handoff.
|
||||
///
|
||||
/// Holds the most-recently-built character in <see cref="LastBuilt"/> so a
|
||||
/// future PlayScreen / world-load step can pick it up without re-running the
|
||||
/// builder, and writes the captured <see cref="PlayerCharacterState"/> to
|
||||
/// <c>user://character.json</c> for cold-restart resumability before the M7
|
||||
/// save format lands.
|
||||
/// </summary>
|
||||
public static class CharacterAssembler
|
||||
{
|
||||
/// <summary>The most recently confirmed character, or null if none.</summary>
|
||||
public static Character? LastBuilt { get; private set; }
|
||||
|
||||
/// <summary>Path used for the resumability dump.</summary>
|
||||
public const string PersistedStatePath = "user://character.json";
|
||||
|
||||
private static IReadOnlyDictionary<string, ItemDef>? _items;
|
||||
|
||||
private static IReadOnlyDictionary<string, ItemDef> Items()
|
||||
{
|
||||
if (_items is not null) return _items;
|
||||
var loader = new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir);
|
||||
var arr = loader.LoadItems();
|
||||
var dict = new Dictionary<string, ItemDef>(arr.Length);
|
||||
foreach (var it in arr) dict[it.Id] = it;
|
||||
_items = dict;
|
||||
return _items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the runtime <see cref="Character"/> from the draft. Returns false
|
||||
/// with a populated <paramref name="error"/> when the draft is incomplete
|
||||
/// or content lookups fail; on success <paramref name="character"/> holds
|
||||
/// the built object and <see cref="LastBuilt"/> is updated.
|
||||
/// </summary>
|
||||
public static bool TryBuild(CharacterDraft draft, out Character? character, out string error)
|
||||
{
|
||||
character = null;
|
||||
error = "";
|
||||
|
||||
var builder = new CharacterBuilder
|
||||
{
|
||||
BaseAbilities = ToAbilityScores(draft.StatAssign),
|
||||
Name = string.IsNullOrWhiteSpace(draft.CharacterName) ? "Wanderer" : draft.CharacterName,
|
||||
};
|
||||
|
||||
// Class + background lookups are shared by purebred and hybrid paths.
|
||||
var classDef = CodexContent.Class(draft.ClassId);
|
||||
if (classDef is null) { error = $"Unknown class id '{draft.ClassId}'."; return false; }
|
||||
builder.ClassDef = classDef;
|
||||
|
||||
var bgDef = CodexContent.Background(draft.BackgroundId);
|
||||
if (bgDef is null) { error = $"Unknown background id '{draft.BackgroundId}'."; return false; }
|
||||
builder.Background = bgDef;
|
||||
|
||||
foreach (Variant v in draft.ChosenSkills)
|
||||
{
|
||||
string raw = (string)v;
|
||||
try { builder.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); }
|
||||
catch (System.ArgumentException) { error = $"Unknown skill id '{raw}'."; return false; }
|
||||
}
|
||||
|
||||
Character built;
|
||||
if (draft.IsHybrid)
|
||||
{
|
||||
builder.IsHybridOrigin = true;
|
||||
|
||||
var sireClade = CodexContent.Clade(draft.SireCladeId);
|
||||
var sireSp = CodexContent.SpeciesById(draft.SireSpeciesId);
|
||||
var damClade = CodexContent.Clade(draft.DamCladeId);
|
||||
var damSp = CodexContent.SpeciesById(draft.DamSpeciesId);
|
||||
if (sireClade is null || sireSp is null || damClade is null || damSp is null)
|
||||
{
|
||||
error = "Hybrid lineage incomplete (sire/dam clade or species missing).";
|
||||
return false;
|
||||
}
|
||||
|
||||
builder.HybridSireClade = sireClade;
|
||||
builder.HybridSireSpecies = sireSp;
|
||||
builder.HybridDamClade = damClade;
|
||||
builder.HybridDamSpecies = damSp;
|
||||
builder.HybridDominantParent =
|
||||
draft.DominantParent == "dam" ? ParentLineage.Dam : ParentLineage.Sire;
|
||||
// Hybrid lineage-bonus picks: the wizard's StepClade picker
|
||||
// records one chosen ability per parent. The builder applies
|
||||
// exactly those (no full ability_mods spread, no species mods)
|
||||
// so the built Character matches the Aside's preview math.
|
||||
builder.HybridSireChosenAbility = draft.SireChosenAbility ?? "";
|
||||
builder.HybridDamChosenAbility = draft.DamChosenAbility ?? "";
|
||||
|
||||
if (!builder.TryBuildHybrid(Items(), out var hybridChar, out error)) return false;
|
||||
built = hybridChar!;
|
||||
}
|
||||
else
|
||||
{
|
||||
var clade = CodexContent.Clade(draft.CladeId);
|
||||
var sp = CodexContent.SpeciesById(draft.SpeciesId);
|
||||
if (clade is null || sp is null)
|
||||
{
|
||||
error = "Lineage incomplete (clade or species missing).";
|
||||
return false;
|
||||
}
|
||||
builder.Clade = clade;
|
||||
builder.Species = sp;
|
||||
|
||||
try { built = builder.Build(Items()); }
|
||||
catch (System.InvalidOperationException ex) { error = ex.Message; return false; }
|
||||
}
|
||||
|
||||
// Subclass selection happens at level 3 mechanically, but the wizard
|
||||
// locks the choice in at L1 — stamp it onto the character so the L3
|
||||
// unlock has the player's pick already recorded.
|
||||
if (!string.IsNullOrEmpty(draft.SubclassId)) built.SubclassId = draft.SubclassId;
|
||||
|
||||
character = built;
|
||||
LastBuilt = built;
|
||||
PersistState(built);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture the live <see cref="Character"/> into a flat
|
||||
/// <see cref="PlayerCharacterState"/> and write it as JSON to
|
||||
/// <see cref="PersistedStatePath"/>. Lets a future cold-load path
|
||||
/// recover the confirmed character before the M7 save format lands.
|
||||
/// </summary>
|
||||
private static void PersistState(Character c)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snap = CharacterCodec.Capture(c);
|
||||
string globalPath = ProjectSettings.GlobalizePath(PersistedStatePath);
|
||||
string? dir = Path.GetDirectoryName(globalPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
string json = JsonSerializer.Serialize(snap, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(globalPath, json);
|
||||
GD.Print($"[character] Persisted state to {PersistedStatePath}");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
GD.PushWarning($"[character] PersistState failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static AbilityScores ToAbilityScores(Godot.Collections.Dictionary statAssign)
|
||||
{
|
||||
int Get(string key) => statAssign.ContainsKey(key) ? (int)statAssign[key] : 10;
|
||||
return new AbilityScores(
|
||||
Get("STR"), Get("DEX"), Get("CON"),
|
||||
Get("INT"), Get("WIS"), Get("CHA"));
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,23 @@ public partial class CharacterDraft : Resource
|
||||
[Export] public string SubclassId { get; set; } = "";
|
||||
[Export] public string BackgroundId { get; set; } = "";
|
||||
|
||||
/// <summary>"male" or "female". Required for every character. For
|
||||
/// purebreds with sex-axis variants (Elk, Lion), drives variant
|
||||
/// resolution. For hybrids, sire variant always resolves to "male"
|
||||
/// and dam variant to "female" by parent-role definition — the
|
||||
/// character's own Sex remains an identity field.</summary>
|
||||
[Export] public string Sex { get; set; } = "";
|
||||
|
||||
/// <summary>Lineage-axis variant id picked for the purebred species
|
||||
/// (e.g. "sheep" or "goat" for Ram-Folk). Empty if the picked species
|
||||
/// has no lineage variant.</summary>
|
||||
[Export] public string SpeciesVariant { get; set; } = "";
|
||||
|
||||
/// <summary>Hybrid: lineage-axis variant for sire's species.</summary>
|
||||
[Export] public string SireSpeciesVariant { get; set; } = "";
|
||||
/// <summary>Hybrid: lineage-axis variant for dam's species.</summary>
|
||||
[Export] public string DamSpeciesVariant { get; set; } = "";
|
||||
|
||||
// ── Phase 6.5 hybrid origin ────────────────────────────────────────
|
||||
/// <summary>True when the PC is a hybrid (two parent lineages).</summary>
|
||||
[Export] public bool IsHybrid { get; set; }
|
||||
@@ -67,6 +84,36 @@ public partial class CharacterDraft : Resource
|
||||
public int CladeTraitLimit(string lineage) =>
|
||||
lineage == DominantParent ? 2 : 1;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the active variant id for a species. <paramref name="role"/>
|
||||
/// is "" for purebred, "sire" or "dam" for hybrid lineages. Returns
|
||||
/// empty when the species has no variants or the relevant pick/sex is
|
||||
/// missing.
|
||||
/// </summary>
|
||||
public string ResolveVariantId(Theriapolis.Core.Data.SpeciesDef? species, string role)
|
||||
{
|
||||
if (species is null || string.IsNullOrEmpty(species.VariantAxis)) return "";
|
||||
return species.VariantAxis switch
|
||||
{
|
||||
// Sex-axis: purebred uses character Sex; hybrid lineages are
|
||||
// pinned by parent role (sire = male, dam = female).
|
||||
"sex" => role switch
|
||||
{
|
||||
"sire" => "male",
|
||||
"dam" => "female",
|
||||
_ => Sex,
|
||||
},
|
||||
// Lineage-axis: explicit per-species pick.
|
||||
"lineage" => role switch
|
||||
{
|
||||
"sire" => SireSpeciesVariant,
|
||||
"dam" => DamSpeciesVariant,
|
||||
_ => SpeciesVariant,
|
||||
},
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the "active" clade for downstream steps (Class / Subclass
|
||||
/// / Background). Purebred uses <see cref="CladeId"/>; hybrids use
|
||||
@@ -149,6 +196,10 @@ public partial class CharacterDraft : Resource
|
||||
case "dam_chosen_species_trait": DamChosenSpeciesTrait = (string)patch[key]; break;
|
||||
case "sire_chosen_species_detriment":SireChosenSpeciesDetriment = (string)patch[key]; break;
|
||||
case "dam_chosen_species_detriment": DamChosenSpeciesDetriment = (string)patch[key]; break;
|
||||
case "sex": Sex = (string)patch[key]; break;
|
||||
case "species_variant": SpeciesVariant = (string)patch[key]; break;
|
||||
case "sire_species_variant": SireSpeciesVariant = (string)patch[key]; break;
|
||||
case "dam_species_variant": DamSpeciesVariant = (string)patch[key]; break;
|
||||
default:
|
||||
GD.PushWarning($"[CharacterDraft] unknown patch key: {k}");
|
||||
break;
|
||||
|
||||
@@ -106,12 +106,19 @@ public static class CodexTheme
|
||||
cardSelected.ShadowOffset = new Vector2(0, 14);
|
||||
theme.SetStylebox("panel_selected", "Card", cardSelected);
|
||||
|
||||
// Popover frame — gild border + soft shadow. Matches .trait-hint.
|
||||
// Popover frame — gild border + soft shadow + rounded corners.
|
||||
// Matches .trait-hint, with a softer corner radius than the rest of
|
||||
// the codex (cards/buttons use 2px sharp) so the floating reveal
|
||||
// reads as a friendlier secondary surface.
|
||||
theme.SetTypeVariation("CodexPopover", "PanelContainer");
|
||||
var popover = new StyleBoxFlat
|
||||
{
|
||||
BgColor = p.Bg2,
|
||||
BorderColor = p.Gild,
|
||||
CornerRadiusTopLeft = 14,
|
||||
CornerRadiusTopRight = 14,
|
||||
CornerRadiusBottomLeft = 14,
|
||||
CornerRadiusBottomRight = 14,
|
||||
ContentMarginLeft = 16,
|
||||
ContentMarginRight = 16,
|
||||
ContentMarginTop = 14,
|
||||
@@ -120,11 +127,14 @@ public static class CodexTheme
|
||||
ShadowSize = 18,
|
||||
ShadowOffset = new Vector2(0, 12),
|
||||
};
|
||||
popover.SetBorderWidthAll(1);
|
||||
popover.SetBorderWidthAll(2);
|
||||
theme.SetStylebox("panel", "CodexPopover", popover);
|
||||
|
||||
// Detriment swap — seal-red border drawn at 3px so the warning reads
|
||||
// unambiguously against the parchment bg even at a glance.
|
||||
var popoverDetriment = (StyleBoxFlat)popover.Duplicate();
|
||||
popoverDetriment.BorderColor = p.Seal;
|
||||
popoverDetriment.SetBorderWidthAll(3);
|
||||
theme.SetStylebox("panel_detriment", "CodexPopover", popoverDetriment);
|
||||
|
||||
// Pill — small trait/skill chip. Mirrors .trait-chips .t-name from
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
namespace Theriapolis.GodotHost.UI;
|
||||
|
||||
/// <summary>
|
||||
/// All 18 skills with display labels, governing ability, and codex
|
||||
/// All 30 skills with display labels, governing ability, and codex
|
||||
/// flavor descriptions. Entries are keyed by their snake_case JSON id —
|
||||
/// the same string that appears in <c>class.skill_options</c> and
|
||||
/// <c>background.skill_proficiencies</c> in <c>Content/Data</c>.
|
||||
///
|
||||
/// Labels and ability mapping mirror Theriapolis.Core.Rules.Stats.SkillId;
|
||||
/// descriptions are ported verbatim from <c>src/data.jsx</c>'s
|
||||
/// <c>SKILL_DESC</c> table in the React prototype. If the JSON schema
|
||||
/// gains a description field later, swap to a data-driven lookup.
|
||||
/// Labels and ability mapping mirror Theriapolis.Core.Rules.Stats.SkillId.
|
||||
/// The 18 d20-baseline descriptions are ported from the React prototype's
|
||||
/// <c>SKILL_DESC</c> table; the 12 M6.18 expansions (5 per ability total)
|
||||
/// are Theriapolis-specific and authored against the design canon.
|
||||
/// </summary>
|
||||
public static class SkillsCatalog
|
||||
{
|
||||
@@ -25,8 +25,24 @@ public static class SkillsCatalog
|
||||
"Knowledge of the older magics: scent-sorcery, blood-sigil, the half-forgotten rites that pre-date the Covenant of Claws."),
|
||||
new("athletics", "Athletics", "STR",
|
||||
"Raw physical effort. Climbing scaffold, swimming the foul canal, hauling a packmate from the pit, breaking a hold."),
|
||||
new("brawl", "Brawl", "STR",
|
||||
"Bare-fanged unarmed violence — claws, teeth, horns, hooves. The coliseum tradition and the back-alley one. Distinct from Athletics (the running body) and Intimidation (the threat unspoken) — this is the application of natural-weapon force."),
|
||||
new("build_read", "Build-Read", "STR",
|
||||
"Sizing up another creature's physical capabilities at a glance. Cross-clade body diversity makes this nontrivial — a Mustelid's frame lies about its strength, a Cervid's stillness lies about its speed. The dockside boxer's eye for who can fight."),
|
||||
new("deception", "Deception", "CHA",
|
||||
"Speaking convincingly past your scent. The art of the false posture, the planted rumor, the answer that is technically true."),
|
||||
new("driving", "Driving", "DEX",
|
||||
"Vehicle and mount control under pressure. Caravan-work, Hare-courier carts, riding a clademorphic beast over broken ground, navigating a contested street at speed."),
|
||||
new("endurance", "Endurance", "CON",
|
||||
"Sustained applied effort. The act of doing hard work for a long time — forced marches, all-night hauls, rowing the canal-shift, the long chase. What you put out, hour after hour."),
|
||||
new("force", "Force", "STR",
|
||||
"Applied violence on objects. Doors, chains, shutters, locked chests, walls that shouldn't be there anymore. The Bulwark's quiet talent and the Claw-Wright's last resort."),
|
||||
new("fortitude", "Fortitude", "CON",
|
||||
"Resisting what you swallow. Ingested poison and contagion, spoiled rations, foreign cuisine, the Goat-Folk welcome of rotted cheese, the dockside whiskey flight, the ritual draught you cannot politely refuse."),
|
||||
new("hardiness", "Hardiness", "CON",
|
||||
"Withstanding external conditions. Temperature extremes, smoke and dust, thin mountain air, the slow attrition of weather. The Polar Bear-Folk's cold tolerance, the Coyote-Folk's heat-of-the-day shift, the herd-wall holding under blizzard."),
|
||||
new("haulage", "Haulage", "STR",
|
||||
"Bearing weight under distance. Porter discipline, stretcher-craft, shouldering an unconscious packmate up a switchback. The Bovid wagon-train's daily reality and the battlefield medic's quiet requirement."),
|
||||
new("history", "History", "INT",
|
||||
"The long memory of Theriapolis — the Imperium's fall, the Compact's ratification, which clade owes which other a centuries-old debt."),
|
||||
new("insight", "Insight", "WIS",
|
||||
@@ -35,10 +51,16 @@ public static class SkillsCatalog
|
||||
"Bared-teeth diplomacy. The threat made plain enough that violence is not required to extract compliance."),
|
||||
new("investigation", "Investigation", "INT",
|
||||
"Methodical search and inference: scene-reading, document-sifting, the patient accumulation of small facts into a verdict."),
|
||||
new("lung_craft", "Lung-Craft", "CON",
|
||||
"Sustained vocalization and breath-control. The physical basis of Muzzle-Speaker oratory, the diver's stillness underwater, the smoke-tolerant veteran's projection across a coliseum without shredding the throat."),
|
||||
new("marksmanship", "Marksmanship", "DEX",
|
||||
"Accurate ranged weapon use — bow, crossbow, sling, javelin, thrown blade. The skill that lets prey-clade militias hold a wall against predator charge, and the Shadow-Pelt's clean answer at distance."),
|
||||
new("medicine", "Medicine", "WIS",
|
||||
"Field surgery, poultice-craft, knowing which clade tolerates which tincture. Stabilizing the dying without finishing them."),
|
||||
new("nature", "Nature", "INT",
|
||||
"Knowledge of the wild outside the city wall — terrain, weather, plant-lore, and the unsigned beasts that observe no Covenant."),
|
||||
new("pain_tolerance", "Pain Tolerance", "CON",
|
||||
"Function while wounded. Holding the line bleeding, finishing the sentence with a knife in your shoulder, performing surgery on yourself. The hybrid medical-mistrust skill — the one you use when the doctor isn't an option."),
|
||||
new("perception", "Perception", "WIS",
|
||||
"Awareness through every sense your clade gives you: ear-cock, scent-prickle, the half-glimpsed shape at the edge of vision."),
|
||||
new("performance", "Performance", "CHA",
|
||||
@@ -47,6 +69,8 @@ public static class SkillsCatalog
|
||||
"Open-handed argument. The case made on its merits, the appeal to mutual benefit, the patient construction of agreement."),
|
||||
new("religion", "Religion", "INT",
|
||||
"The hymn-cycles of the Cervid liturgy, the Compact's sacred clauses, the small household rites your clade keeps without thinking."),
|
||||
new("scent_speak", "Scent-Speak", "CHA",
|
||||
"Deliberate pheromone communication — the unspoken half of every Theriapolis conversation. Broadcasting calm, threat, deference, lineage, trust. The Scent-Broker's craft, the passer's nightmare, the perfumer's living. Distinct from Insight (which reads the scent) — this is the active emission."),
|
||||
new("sleight_of_hand", "Sleight of Hand", "DEX",
|
||||
"Quiet fingers — pickpocketing, palmed coins, the swap performed under another's nose. Useful in markets and courtrooms alike."),
|
||||
new("stealth", "Stealth", "DEX",
|
||||
|
||||
@@ -33,6 +33,7 @@ public static class WizardValidation
|
||||
|
||||
private static string? ValidateClade(CharacterDraft draft)
|
||||
{
|
||||
if (string.IsNullOrEmpty(draft.Sex)) return "Pick a sex.";
|
||||
if (draft.IsHybrid)
|
||||
{
|
||||
if (string.IsNullOrEmpty(draft.SireCladeId)) return "Pick a sire clade.";
|
||||
@@ -85,9 +86,23 @@ public static class WizardValidation
|
||||
&& string.IsNullOrEmpty(draft.DamChosenSpeciesDetriment))
|
||||
return "Pick a dam species detriment.";
|
||||
|
||||
// Lineage-axis variants: each parent species needs an
|
||||
// explicit lineage pick when applicable.
|
||||
if (sireSp is not null && sireSp.VariantAxis == "lineage"
|
||||
&& string.IsNullOrEmpty(draft.SireSpeciesVariant))
|
||||
return "Pick a sire species lineage.";
|
||||
if (damSp is not null && damSp.VariantAxis == "lineage"
|
||||
&& string.IsNullOrEmpty(draft.DamSpeciesVariant))
|
||||
return "Pick a dam species lineage.";
|
||||
|
||||
return null;
|
||||
}
|
||||
return string.IsNullOrEmpty(draft.SpeciesId) ? "Pick a species." : null;
|
||||
if (string.IsNullOrEmpty(draft.SpeciesId)) return "Pick a species.";
|
||||
var purebredSp = CodexContent.SpeciesById(draft.SpeciesId);
|
||||
if (purebredSp is not null && purebredSp.VariantAxis == "lineage"
|
||||
&& string.IsNullOrEmpty(draft.SpeciesVariant))
|
||||
return "Pick a species lineage.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateSkills(CharacterDraft draft)
|
||||
|
||||
@@ -113,7 +113,8 @@ public sealed class ContentLoadTests
|
||||
[InlineData("rabbit", -1, 2, 0, 0, 1, 0)]
|
||||
[InlineData("hare", -1, 2, 1, 0, 0, 0)]
|
||||
[InlineData("bull", 2, 0, 1, 0, 0, 0)]
|
||||
[InlineData("ram", 1, 0, 1, 0, 1, 0)]
|
||||
[InlineData("sheep", 1, 0, 1, 0, 1, 0)]
|
||||
[InlineData("goat", 1, 0, 1, 0, 1, 0)]
|
||||
[InlineData("bison", 1, 0, 2, 0, 0, 0)]
|
||||
public void Clade_Plus_Species_AbilityMods_MatchQuickRefTable(
|
||||
string speciesId, int str, int dex, int con, int @int, int wis, int cha)
|
||||
|
||||
@@ -93,27 +93,60 @@ public sealed class HybridCharacterTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuildHybrid_BlendsAbilityMods()
|
||||
public void TryBuildHybrid_AppliesChosenAbilityFromEachParentClade()
|
||||
{
|
||||
// Wolf-Folk Sire:
|
||||
// canidae clade: +1 CON, +1 WIS
|
||||
// wolf species: +1 STR
|
||||
// × Rabbit-Folk Dam:
|
||||
// leporidae clade: -1 STR, +2 DEX
|
||||
// rabbit species: +1 WIS
|
||||
// Hybrid PCs take ONE ability mod from each parent clade — the
|
||||
// wizard's StepClade picker records the choices, and the builder
|
||||
// applies exactly those. Species mods don't apply for hybrids.
|
||||
//
|
||||
// Wolf-Folk Sire (canidae: +1 CON, +1 WIS) — sire picks CON.
|
||||
// Rabbit-Folk Dam (leporidae: -1 STR, +2 DEX) — dam picks DEX.
|
||||
// Base 10 across the board.
|
||||
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
|
||||
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
|
||||
b.HybridSireChosenAbility = "CON";
|
||||
b.HybridDamChosenAbility = "DEX";
|
||||
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
|
||||
Assert.True(ok);
|
||||
Assert.Equal(10, c!.Abilities.STR); // no -1 (dam didn't pick STR), no +1 (no species mods)
|
||||
Assert.Equal(12, c.Abilities.DEX); // dam pick: +2
|
||||
Assert.Equal(11, c.Abilities.CON); // sire pick: +1
|
||||
Assert.Equal(10, c.Abilities.WIS); // no +1 (sire picked CON, not WIS)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuildHybrid_StacksWhenBothParentsPickSameAbility()
|
||||
{
|
||||
// Canidae gives +1 CON, ursidae gives +2 CON. If both parents pick
|
||||
// CON, both bonuses apply additively.
|
||||
var b = NewBuilderWithClassAndSkills();
|
||||
b.HybridSireClade = _content.Clades["canidae"];
|
||||
b.HybridSireSpecies = _content.Species["wolf"];
|
||||
b.HybridDamClade = _content.Clades["ursidae"];
|
||||
b.HybridDamSpecies = _content.Species["brown_bear"];
|
||||
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
|
||||
b.HybridSireChosenAbility = "CON";
|
||||
b.HybridDamChosenAbility = "CON";
|
||||
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
|
||||
Assert.True(ok);
|
||||
Assert.Equal(13, c!.Abilities.CON); // 10 + 1 (canid) + 2 (ursid)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuildHybrid_NoBonusWhenPickIsEmpty()
|
||||
{
|
||||
// Defensive: empty pick = no bonus from that side. Headless tests
|
||||
// and old saves leave the field blank; the builder should not
|
||||
// silently fall back to the old "apply everything" rule.
|
||||
var b = MakeHybridBuilder("canidae", "wolf", "leporidae", "rabbit");
|
||||
b.BaseAbilities = new AbilityScores(10, 10, 10, 10, 10, 10);
|
||||
// No sire or dam pick set.
|
||||
bool ok = b.TryBuildHybrid(_content.Items, out var c, out _);
|
||||
Assert.True(ok);
|
||||
// Net STR: 10 + 1 (wolf) - 1 (leporid) = 10.
|
||||
// Net DEX: 10 + 2 (leporid) = 12.
|
||||
// Net CON: 10 + 1 (canid) = 11.
|
||||
// Net WIS: 10 + 1 (canid) + 1 (rabbit) = 12.
|
||||
Assert.Equal(10, c!.Abilities.STR);
|
||||
Assert.Equal(12, c.Abilities.DEX);
|
||||
Assert.Equal(11, c.Abilities.CON);
|
||||
Assert.Equal(12, c.Abilities.WIS);
|
||||
Assert.Equal(10, c.Abilities.DEX);
|
||||
Assert.Equal(10, c.Abilities.CON);
|
||||
Assert.Equal(10, c.Abilities.WIS);
|
||||
}
|
||||
|
||||
// ── Cross-clade pairings smoke ────────────────────────────────────────
|
||||
@@ -124,7 +157,7 @@ public sealed class HybridCharacterTests
|
||||
[InlineData("ursidae", "brown_bear", "bovidae", "bull")]
|
||||
[InlineData("felidae", "leopard", "cervidae", "deer")]
|
||||
[InlineData("mustelidae","badger", "leporidae", "rabbit")]
|
||||
[InlineData("bovidae", "ram", "cervidae", "elk")]
|
||||
[InlineData("bovidae", "sheep", "cervidae", "elk")]
|
||||
[InlineData("leporidae", "rabbit", "felidae", "housecat")]
|
||||
public void TryBuildHybrid_AllCrossCladeCombinationsValid(
|
||||
string sireClade, string sireSpecies, string damClade, string damSpecies)
|
||||
|
||||
Reference in New Issue
Block a user