diff --git a/data/srd/subclasses.json b/data/srd/subclasses.json new file mode 100644 index 00000000..6c34edb8 --- /dev/null +++ b/data/srd/subclasses.json @@ -0,0 +1,313 @@ +{ + "_attribution": "Subclass options & choice-level features curated from SRD 5.2.4 (CC-BY-4.0, Wizards of the Coast). One canonical subclass per class per the SRD subset; additive — the DM may still confirm a world-canon tradition by name.", + "subclass_level": { + "druid": 3, + "fighter": 3, + "bard": 3, + "sorcerer": 3, + "wizard": 3, + "warlock": 3, + "ranger": 3, + "cleric": 3, + "paladin": 3, + "barbarian": 3, + "rogue": 3, + "monk": 3 + }, + "classes": { + "barbarian": { + "group_label": "Primal Path", + "options": [ + { + "name": "Path of the Berserker", + "desc": "Primal Path: gains Frenzy at level 3.", + "aliases": [ + "Berserker", + "Path of Berserker" + ], + "features": [ + { + "name": "Frenzy", + "desc": "If you use Reckless Attack while your Rage is active, you deal extra damage to the first target you hit on your turn with a Strength-based attack. To determine the extra damage, roll a number of d6s equal to your Rage Damage bonus, and add them together. The damage has the same type as the weapon or Unarmed Strike used for the attack." + } + ] + } + ], + "aliases": { + "Berserker": "Path of the Berserker", + "Path of Berserker": "Path of the Berserker" + } + }, + "bard": { + "group_label": "Bard College", + "options": [ + { + "name": "College of Lore", + "desc": "Bard College: gains Bonus Proficiencies, Cutting Words at level 3.", + "aliases": [ + "Lore" + ], + "features": [ + { + "name": "Bonus Proficiencies", + "desc": "You gain proficiency with three skills of your choice." + }, + { + "name": "Cutting Words", + "desc": "You learn to use your wit to supernaturally distract, confuse, and otherwise sap the confidence and competence of others. When a creature that you can see within 60 feet of yourself makes a damage roll or succeeds on an ability check or attack roll, you can take a Reaction to expend one use of your Bardic Inspiration; roll your Bardic Inspiration die, and subtract the number rolled from the creature's roll, reducing the damage or potentially turning the success into a failure." + } + ] + } + ], + "aliases": { + "Lore": "College of Lore" + } + }, + "cleric": { + "group_label": "Divine Domain", + "options": [ + { + "name": "Life Domain", + "desc": "Divine Domain: gains Disciple of Life, Life Domain Spells, Preserve Life at level 3.", + "aliases": [ + "Life" + ], + "features": [ + { + "name": "Disciple of Life", + "desc": "When a spell you cast with a spell slot restores Hit Points to a creature, that creature regains additional Hit Points on the turn you cast the spell. The additional Hit Points equal 2 plus the spell slot's level." + }, + { + "name": "Life Domain Spells", + "desc": "Your connection to this divine domain ensures you always have certain spells ready. When you reach a Cleric level specified in the Life Domain Spells table, you thereafter always have the listed spells prepared." + }, + { + "name": "Preserve Life", + "desc": "As a Magic action, you present your Holy Symbol and expend a use of your Channel Divinity to evoke healing energy that can restore a number of Hit Points equal to five times your Cleric level. Choose Bloodied creatures within 30 feet of yourself (which can include you), and divide those Hit Points among them. This feature can restore a creature to no more than half its Hit Point maximum." + } + ] + } + ], + "aliases": { + "Life": "Life Domain" + } + }, + "druid": { + "group_label": "Druid Circle", + "options": [ + { + "name": "Circle of the Land", + "desc": "Druid Circle: gains Circle of the Land Spells, Land's Aid at level 3.", + "aliases": [ + "Land" + ], + "features": [ + { + "name": "Circle of the Land Spells", + "desc": "Whenever you finish a Long Rest, choose one type of land: arid, polar, temperate, or tropical. Consult the table below that corresponds to the chosen type; you have the spells listed for your Druid level and lower prepared." + }, + { + "name": "Land's Aid", + "desc": "As a Magic action, you can expend a use of your Wild Shape and choose a point within 60 feet of yourself. Vitality-giving flowers and life-draining thorns appear for a moment in a 10-foot-radius Sphere centered on that point. Each creature of your choice in the Sphere must make a Constitution saving throw against your spell save DC, taking 2d6 Necrotic damage on a failed save or half as much damage on a successful one. One creature of your choice in that area regains 2d6 Hit Points." + } + ] + } + ], + "aliases": { + "Land": "Circle of the Land" + } + }, + "fighter": { + "group_label": "Martial Archetype", + "options": [ + { + "name": "Champion", + "desc": "Martial Archetype: gains Improved Critical, Remarkable Athlete at level 3.", + "aliases": [], + "features": [ + { + "name": "Improved Critical", + "desc": "Your attack rolls with weapons and Unarmed Strikes can score a Critical Hit on a roll of 19 or 20 on the d20." + }, + { + "name": "Remarkable Athlete", + "desc": "Thanks to your athleticism, you have Advantage on Initiative rolls and Strength (Athletics) checks." + } + ] + } + ], + "aliases": {} + }, + "monk": { + "group_label": "Monastic Tradition", + "options": [ + { + "name": "Warrior of the Open Hand", + "desc": "Monastic Tradition: gains Open Hand Technique at level 3.", + "aliases": [ + "Open Hand" + ], + "features": [ + { + "name": "Open Hand Technique", + "desc": "Whenever you hit a creature with an attack granted by your Flurry of Blows, you can impose one of the following effects on that target." + } + ] + } + ], + "aliases": { + "Open Hand": "Warrior of the Open Hand" + } + }, + "paladin": { + "group_label": "Sacred Oath", + "options": [ + { + "name": "Oath of Devotion", + "desc": "Sacred Oath: gains Oath of Devotion Spells, Sacred Weapon at level 3.", + "aliases": [ + "Devotion" + ], + "features": [ + { + "name": "Oath of Devotion Spells", + "desc": "The magic of your oath ensures you always have certain spells ready; when you reach a Paladin level specified in the Oath of Devotion Spells table, you thereafter always have the listed spells prepared." + }, + { + "name": "Sacred Weapon", + "desc": "When you take the Attack action, you can expend one use of your Channel Divinity to imbue one Melee weapon that you are holding with positive energy. For 10 minutes or until you use this feature again, you add your Charisma modifier to attack rolls you make with that weapon (minimum bonus of +1), and each time you hit with it, you cause it to deal its normal damage type or Radiant damage." + } + ] + } + ], + "aliases": { + "Devotion": "Oath of Devotion" + } + }, + "ranger": { + "group_label": "Ranger Archetype", + "options": [ + { + "name": "Hunter", + "desc": "Ranger Archetype: gains Hunter's Lore, Hunter's Prey at level 3.", + "aliases": [], + "features": [ + { + "name": "Hunter's Lore", + "desc": "You can call on the forces of nature to reveal certain strengths and weaknesses of your prey. While a creature is marked by your *Hunter's Mark*, you know whether that creature has any Immunities, Resistances, or Vulnerabilities, and if the creature has any, you know what they are." + }, + { + "name": "Hunter's Prey", + "desc": "You gain one of the following feature options of your choice. Whenever you finish a Short or Long Rest, you can replace the chosen option with the other one." + } + ] + } + ], + "aliases": {} + }, + "rogue": { + "group_label": "Roguish Archetype", + "options": [ + { + "name": "Thief", + "desc": "Roguish Archetype: gains Fast Hands, Second-Story Work at level 3.", + "aliases": [], + "features": [ + { + "name": "Fast Hands", + "desc": "As a Bonus Action, you can do one of the following." + }, + { + "name": "Second-Story Work", + "desc": "You've trained to get into especially hard-to-reach places, granting you these benefits." + } + ] + } + ], + "aliases": {} + }, + "sorcerer": { + "group_label": "Sorcerous Origin", + "options": [ + { + "name": "Draconic Sorcery", + "desc": "Sorcerous Origin: gains Draconic Resilience, Draconic Spells at level 3.", + "aliases": [ + "Draconic", + "Draconic Bloodline" + ], + "features": [ + { + "name": "Draconic Resilience", + "desc": "The magic in your body manifests physical traits of your draconic gift. Your Hit Point maximum increases by 3, and it increases by 1 whenever you gain another Sorcerer level." + }, + { + "name": "Draconic Spells", + "desc": "When you reach a Sorcerer level specified in the Draconic Spells table, you thereafter always have the listed spells prepared." + } + ] + } + ], + "aliases": { + "Draconic": "Draconic Sorcery", + "Draconic Bloodline": "Draconic Sorcery" + } + }, + "warlock": { + "group_label": "Otherworldly Patron", + "options": [ + { + "name": "Fiend Patron", + "desc": "Otherworldly Patron: gains Dark One's Blessing, Fiend Spells at level 3.", + "aliases": [ + "Fiend", + "The Fiend" + ], + "features": [ + { + "name": "Dark One's Blessing", + "desc": "When you reduce an enemy to 0 Hit Points, you gain Temporary Hit Points equal to your Charisma modifier plus your Warlock level (minimum of 1 Temporary Hit Point). You also gain this benefit if someone else reduces an enemy within 10 feet of you to 0 Hit Points." + }, + { + "name": "Fiend Spells", + "desc": "The magic of your patron ensures you always have certain spells ready; when you reach a Warlock level specified in the Fiend Spells table, you thereafter always have the listed spells prepared." + } + ] + } + ], + "aliases": { + "Fiend": "Fiend Patron", + "The Fiend": "Fiend Patron" + } + }, + "wizard": { + "group_label": "Arcane Tradition", + "options": [ + { + "name": "Evoker", + "desc": "Arcane Tradition: gains Evocation Savant, Sculpt Spells at level 3.", + "aliases": [ + "Evocation", + "Evocation Wizard", + "School of Evocation" + ], + "features": [ + { + "name": "Evocation Savant", + "desc": "Choose two Wizard spells from the Evocation school, each of which must be no higher than level 2, and add them to your spellbook for free." + }, + { + "name": "Sculpt Spells", + "desc": "You can create pockets of relative safety within the effects of your evocations. When you cast an Evocation spell that affects other creatures that you can see, you can choose a number of them equal to 1 plus the spell's level. The chosen creatures automatically succeed on their saving throws against the spell, and they take no damage if they would normally take half damage on a successful save." + } + ] + } + ], + "aliases": { + "Evocation": "Evoker", + "School of Evocation": "Evoker", + "Evocation Wizard": "Evoker" + } + } + } +} \ No newline at end of file diff --git a/servers/engine/server.py b/servers/engine/server.py index 96a1621b..967081ef 100644 --- a/servers/engine/server.py +++ b/servers/engine/server.py @@ -1113,7 +1113,21 @@ def _apply_srd_class_defaults(ch, class_name: str, level: int, set_base_ac: bool ch.armor_class = 10 + dex + ch.abilities.modifier(Ability.WIS) else: ch.armor_class = srd_tables.class_base_ac(cname) - for f in srd_tables.features_through(cname, level): + through = list(srd_tables.features_through(cname, level)) + # #624: a character created directly at/above its subclass-choice level WITH a + # subclass also gets that subclass's choice-level features (normalize loose + # names — 'Evocation' -> 'Evoker' — and persist the canonical form). + sub = next((cl.subclass for cl in ch.classes if cl.name.lower() == cname), None) + if sub: + canonical = srd_tables.resolve_subclass(cname, sub) + if canonical: + for cl in ch.classes: + if cl.name.lower() == cname: + cl.subclass = canonical + slvl = srd_tables.subclass_level(cname) + if slvl is not None and level >= slvl: + through += srd_tables.subclass_features_at(cname, canonical, slvl) + for f in through: if f["name"] not in ch.features: ch.features.append(f["name"]) if "extra_attacks" in f: @@ -4755,6 +4769,12 @@ def level_up( base = srd_tables.average_hp(die) gain = max(1, base + con) + # Normalize a chosen subclass to its canonical SRD name when the table knows + # it ('Evocation' -> 'Evoker'); an unknown/world-canon name passes through + # verbatim (additive — the DM still finalizes a homebrew tradition by name). + if subclass: + subclass = srd_tables.resolve_subclass(cname, subclass) or subclass + if existing: existing.level += 1 if subclass: @@ -4788,6 +4808,13 @@ def level_up( # grants real features (and the mechanical hints the engine references), # not just HP and slots. gained = srd_tables.features_at(cname, new_class_level) + # #624: if the character chose a subclass and this is the subclass-choice + # level, also grant that subclass's choice-level features (e.g. an Evoker's + # Evocation Savant + Sculpt Spells) — not just the generic placeholder. + cur_subclass = next( + (cl.subclass for cl in ch.classes if cl.name.lower() == cname), None + ) + gained = gained + srd_tables.subclass_features_at(cname, cur_subclass, new_class_level) for f in gained: if f["name"] not in ch.features: ch.features.append(f["name"]) @@ -5002,6 +5029,25 @@ def _build_option_from_preview(preview: dict, feats_allowed: bool, multiclass_al } +def _subclass_block_for(cname: str, next_class_level: int, current_subclass: Optional[str]) -> Optional[dict]: + """#624: the subclass-choice block a build option carries when leveling INTO a + class's subclass-choice level without a subclass already set — the legal SRD + options (each with a brief feature preview) so the surface renders a real list + instead of a free-text box. None when no choice is due at this level.""" + slvl = srd_tables.subclass_level(cname) + if slvl is None or next_class_level != slvl: + return None + options = srd_tables.subclass_options(cname) + if not options: + return None + return { + "required": not bool((current_subclass or "").strip()), + "group_label": srd_tables.subclass_group_label(cname), + "current": current_subclass, + "options": options, + } + + @mcp.tool() def build_options(campaign_id: str, character_id: str) -> dict: """Return legal one-level build paths for a character without mutating state. @@ -5022,6 +5068,9 @@ def build_options(campaign_id: str, character_id: str) -> dict: options: list[dict] = [] blocked_options: list[dict] = [] + existing_subclass = { + cl.name.lower(): cl.subclass for cl in before.classes + } for cname in class_names: preview = preview_level_up(campaign_id, character_id, cname) option = _build_option_from_preview( @@ -5029,6 +5078,12 @@ def build_options(campaign_id: str, character_id: str) -> dict: c.house_rules.feats_allowed, c.house_rules.multiclass_allowed, ) + # #624: surface the subclass picker (options + previews) when this path + # levels into the class's subclass-choice level without one chosen yet. + next_class_level = preview["to"]["class_level"] + sub_block = _subclass_block_for(cname, next_class_level, existing_subclass.get(cname)) + if sub_block is not None: + option["subclass"] = sub_block if option["legal"]: options.append(option) else: diff --git a/servers/engine/srd_tables.py b/servers/engine/srd_tables.py index d5b18bc0..61d721cb 100644 --- a/servers/engine/srd_tables.py +++ b/servers/engine/srd_tables.py @@ -105,6 +105,77 @@ def features_through(class_name: str, level: int) -> list[dict]: return out +# ── Subclass (Arcane Tradition / Domain / Archetype …) options — #624 ──────────── +# +# SRD 5.2 every class picks its subclass at a fixed level (3). The engine OWNS the +# legal options + their choice-level features so the level-up surface can present a +# real list with previews instead of a free-text box, and so choosing one applies +# its features. Additive: a class/subclass the table doesn't know still round-trips +# (subclass stays a free string; nothing is forced). + + +def _subclasses() -> dict: + return _load("subclasses") + + +def subclass_level(class_name: str) -> int | None: + """The character level at which `class_name` chooses its subclass (SRD 5.2: 3), + or None if the class is unknown to the subclass table.""" + return _subclasses().get("subclass_level", {}).get(class_name.lower()) + + +def subclass_group_label(class_name: str) -> str: + """The in-world name for this class's subclass category (e.g. 'Arcane Tradition' + for wizard, 'Divine Domain' for cleric). Empty if unknown.""" + return _subclasses().get("classes", {}).get(class_name.lower(), {}).get("group_label", "") + + +def subclass_options(class_name: str) -> list[dict]: + """Legal SRD subclass options for a class, each ``{name, desc, aliases, features}`` + where `features` are the choice-level (level-3) features that subclass grants. + Empty if the class has no SRD subclass entry.""" + entry = _subclasses().get("classes", {}).get(class_name.lower()) + if not entry: + return [] + return [dict(o) for o in entry.get("options", [])] + + +def resolve_subclass(class_name: str, name: str | None) -> str | None: + """Resolve a (possibly loose) subclass name to its canonical SRD name for a + class. Matches the canonical name case-insensitively and a curated alias map + ('Evocation' -> 'Evoker'). Returns None if the name doesn't match any option.""" + if not name: + return None + entry = _subclasses().get("classes", {}).get(class_name.lower()) + if not entry: + return None + want = name.strip().lower() + for opt in entry.get("options", []): + if opt["name"].lower() == want: + return opt["name"] + for alias, canonical in entry.get("aliases", {}).items(): + if alias.lower() == want: + return canonical + return None + + +def subclass_features_at(class_name: str, subclass: str | None, class_level: int) -> list[dict]: + """The chosen subclass's features gained AT this class level. Currently the + curated table carries the choice-level (level-3) features; later-level subclass + features remain represented by the generic 'Subclass Feature' placeholders in + class_features.json. Empty if no subclass, an unknown subclass, or no features + at this level.""" + canonical = resolve_subclass(class_name, subclass) + if not canonical: + return [] + if class_level != subclass_level(class_name): + return [] + for opt in subclass_options(class_name): + if opt["name"] == canonical: + return [dict(f) for f in opt.get("features", [])] + return [] + + def caster_type(name: str) -> str: return class_data(name)["caster_type"] diff --git a/servers/engine/tests/test_class_features.py b/servers/engine/tests/test_class_features.py index 82cddbe5..6ec3136c 100644 --- a/servers/engine/tests/test_class_features.py +++ b/servers/engine/tests/test_class_features.py @@ -53,3 +53,81 @@ def test_rogue_sneak_attack_scales_on_level_up(cid): assert server.get_character(cid, rid)["sneak_attack_dice"] == "1d6" server.level_up(cid, rid, "Rogue") # -> level 3 assert server.get_character(cid, rid)["sneak_attack_dice"] == "2d6" + + +# ── #624: subclass (Arcane Tradition) options are EXPOSED, not free-text ────────── + + +def test_subclass_level_table(): + # Every SRD class chooses its subclass at a known level (most at 3; warlock at 3 + # too in SRD 5.2). The engine knows WHEN, so the surface can flag the choice. + assert srd_tables.subclass_level("wizard") == 3 + assert srd_tables.subclass_level("cleric") == 3 + assert srd_tables.subclass_level("fighter") == 3 + + +def test_wizard_subclass_options_exposed_with_preview(): + opts = srd_tables.subclass_options("wizard") + assert opts, "wizard must expose at least one Arcane Tradition option" + names = {o["name"] for o in opts} + assert "Evoker" in names # the SRD 5.2 Arcane Tradition + evoker = next(o for o in opts if o["name"] == "Evoker") + # The option carries a brief feature preview so the picker isn't a blind text box. + assert evoker.get("desc"), "subclass option must carry a description/preview" + assert evoker.get("features"), "subclass option must list the features it grants" + assert any("Evocation Savant" in f["name"] or "Sculpt Spells" in f["name"] + for f in evoker["features"]) + + +def test_subclass_options_match_by_alias(): + # The player/DM may name the tradition loosely ("Evocation") — the engine + # resolves it to the canonical SRD subclass ("Evoker"). + resolved = srd_tables.resolve_subclass("wizard", "Evocation") + assert resolved == "Evoker" + assert srd_tables.resolve_subclass("wizard", "Evoker") == "Evoker" + assert srd_tables.resolve_subclass("wizard", "not-a-real-tradition") is None + + +def test_level_up_to_subclass_level_applies_subclass_features(cid): + # A Wizard reaching L3 and choosing the Evocation tradition gains its L3 + # features (Evocation Savant, Sculpt Spells) — not just the generic placeholder. + wid = server.create_character( + cid, "Gale", kind="player", class_name="Wizard", level=2, + abilities={"intelligence": 16, "constitution": 14}, apply_srd_defaults=True, + )["id"] + out = server.level_up(cid, wid, "Wizard", subclass="Evocation") # -> level 3 + assert out["classes"][0]["subclass"] == "Evoker" # normalized to canonical SRD name + gained = {f["name"] for f in out["_features_gained"]} + assert "Evocation Savant" in gained and "Sculpt Spells" in gained + sheet = server.get_character(cid, wid) + assert "Evocation Savant" in sheet["features"] + assert "Sculpt Spells" in sheet["features"] + + +def test_create_wizard_at_subclass_level_applies_subclass_features(cid): + # A Wizard CREATED directly at L3 with a subclass gets its subclass features too + # (features_through, the from-scratch path). + wid = server.create_character( + cid, "Tara", kind="player", class_name="Wizard", level=3, subclass="Evoker", + abilities={"intelligence": 16, "constitution": 14}, apply_srd_defaults=True, + )["id"] + sheet = server.get_character(cid, wid) + assert "Evocation Savant" in sheet["features"] + assert "Sculpt Spells" in sheet["features"] + + +def test_build_options_exposes_subclass_choice_at_subclass_level(cid): + # The build planner the /character surface reads must surface the legal subclass + # options (with previews) when the next level grants a subclass — so the picker + # presents a real list instead of a free-text box. + wid = server.create_character( + cid, "Nyx", kind="player", class_name="Wizard", level=2, + abilities={"intelligence": 16, "constitution": 14}, apply_srd_defaults=True, + )["id"] + planner = server.build_options(cid, wid) + wiz_opt = next(o for o in planner["options"] if o["class_name"] == "wizard") + sub = wiz_opt.get("subclass") + assert sub and sub["required"] is True + names = {o["name"] for o in sub["options"]} + assert "Evoker" in names + assert all(o.get("desc") for o in sub["options"]) diff --git a/servers/engine/tests/test_codex_provider_wrapper.py b/servers/engine/tests/test_codex_provider_wrapper.py index d79eb488..7c205d54 100644 --- a/servers/engine/tests/test_codex_provider_wrapper.py +++ b/servers/engine/tests/test_codex_provider_wrapper.py @@ -215,7 +215,9 @@ def test_codex_dm_wrapper_seed_smoke_surfaces_origin_template_sheet(tmp_path): pc = seed["pc"] assert pc["class"] == "Wizard" assert pc["level"] == 3 - assert pc["subclass"] == "Evocation" + # #624: the origin template's loose "Evocation" is normalized to the canonical SRD + # subclass "Evoker" (and Rolan now actually gains its level-3 features at seed time). + assert pc["subclass"] == "Evoker" assert "Magic Missile" in pc["spells"] assert "Mage Armor" in pc["spells"] diff --git a/servers/engine/tests/test_qa_fixes.py b/servers/engine/tests/test_qa_fixes.py index d5f9290d..3f613862 100644 --- a/servers/engine/tests/test_qa_fixes.py +++ b/servers/engine/tests/test_qa_fixes.py @@ -996,7 +996,9 @@ def test_update_character_accepts_flat_level_class_aliases(tmp_path, monkeypatch ch = server.update_character(bg, cidp, { "level": 3, "class_name": "Wizard", "subclass": "School of Evocation"}) assert ch["classes"][0]["level"] == 3 - assert ch["classes"][0]["subclass"] == "School of Evocation" + # #624: a known loose subclass name is normalized to its canonical SRD form + # ("School of Evocation" -> "Evoker"); an unknown/world-canon name passes through. + assert ch["classes"][0]["subclass"] == "Evoker" assert ch["proficiency_bonus"] == 2 # L3 -> +2 recomputed from the new level # A genuine typo is still rejected (the model's strict-typo guard is untouched). with pytest.raises(Exception): diff --git a/viewer/openworlds/screen-character.jsx b/viewer/openworlds/screen-character.jsx index 735e18b4..a7554f47 100644 --- a/viewer/openworlds/screen-character.jsx +++ b/viewer/openworlds/screen-character.jsx @@ -375,6 +375,13 @@ function LevelUpModal({ hero, campaignId, onClose, onDone, toast }) { // already flagged one pending (created above the choose-level). Either way the player names it. const subclassDue = !!hero.pendingSubclass || featuresGained.some((f) => /subclass/i.test((f && f.name) || "")); + // #624: the engine now exposes the legal SRD subclass options (with a feature preview) for the + // chosen class at its subclass level. Present them as a pickable list instead of a blind text box — + // selecting one fills `subclassName`. The free-text input REMAINS for any world-canon tradition the + // engine's SRD table doesn't enumerate (additive: the DM still finalizes a homebrew name). + const subclassBlock = (option && option.subclass) || null; + const subclassOptions = (subclassBlock && Array.isArray(subclassBlock.options)) ? subclassBlock.options : []; + const subclassGroupLabel = (subclassBlock && subclassBlock.group_label) || "subclass"; const asiRequired = !!(option && option.choices && option.choices.asi_required); const featAllowed = !!(option && option.choices && option.choices.feat_allowed); const toLevel = (option && option.to && option.to.level) || (Number(hero.level) + 1); @@ -490,13 +497,43 @@ function LevelUpModal({ hero, campaignId, onClose, onDone, toast }) { )} {subclassDue && ( -
- This level grants a subclass. Name the one your character takes — the DM confirms it against the world's options. -
++ This level grants a {subclassGroupLabel}. Pick one of the options below (each shows what it grants), + or name a different one your world offers — the DM finalizes it. +
++ This level grants a subclass. Name the one your character takes — the DM confirms it against the world's options. +
+ )} setSubclassName(e.target.value)} - placeholder="e.g. School of Evocation" + placeholder={subclassOptions.length > 0 ? "…or type another tradition your world offers" : "e.g. School of Evocation"} data-worldos-testid="levelup-subclass-input" style={{ width: "100%", padding: "8px 10px", boxSizing: "border-box", diff --git a/viewer/tests/test_build_options_bridge.py b/viewer/tests/test_build_options_bridge.py index c80deaec..18010d5a 100644 --- a/viewer/tests/test_build_options_bridge.py +++ b/viewer/tests/test_build_options_bridge.py @@ -59,6 +59,29 @@ def test_build_options_response_rejects_unsafe_or_missing_campaign_id(self): self.assertEqual(response["code"], "invalid_campaign") self.assertIn("campaign", response["errors"][0]) + def test_build_options_surfaces_subclass_options_at_subclass_level(self): + # #624: a Wizard one level below its subclass-choice level (L2 -> L3) must see + # the legal Arcane Tradition options (with previews) on its continue-wizard path, + # so the /character picker renders a real list instead of a free-text box. + engine = server._engine_server() + campaign_id = engine.create_campaign("Subclass")["id"] + character_id = engine.create_character( + campaign_id, "Gale", kind="player", class_name="Wizard", level=2, + apply_srd_defaults=True, + abilities={"intelligence": 16, "constitution": 14, "dexterity": 12}, + )["id"] + response = server.build_options_response(campaign_id, character_id) + self.assertTrue(response["ok"]) + wizard = next(o for o in response["planner"]["options"] if o["class_name"] == "wizard") + sub = wizard.get("subclass") + self.assertIsNotNone(sub, "wizard at L2->L3 must carry a subclass-choice block") + self.assertTrue(sub["required"]) + self.assertEqual(sub["group_label"], "Arcane Tradition") + names = {o["name"] for o in sub["options"]} + self.assertIn("Evoker", names) + self.assertTrue(all(o.get("desc") for o in sub["options"])) + self.assertTrue(all(o.get("features") for o in sub["options"])) + if __name__ == "__main__": unittest.main() diff --git a/viewer/tests/test_levelup_picker.py b/viewer/tests/test_levelup_picker.py index e86b757d..a56205f2 100644 --- a/viewer/tests/test_levelup_picker.py +++ b/viewer/tests/test_levelup_picker.py @@ -13,8 +13,9 @@ an always-on button; 2. the modal reads the engine planner (/build-options) — real HP/features, nothing faked; 3. the confirm WRITES for real (POST /move kind:"do") and is never permanently disabled; - 4. the subclass is a NAMED input the DM finalizes — NOT a hardcoded dropdown (the engine does not - enumerate world-canon subclasses). + 4. the subclass picker presents the engine-exposed SRD options (with feature previews) AND keeps a + named free-text input for any world-canon tradition the engine's SRD table doesn't enumerate + (#624: the options come FROM the engine planner — never a JSX-hardcoded list). """ import re @@ -78,13 +79,19 @@ def test_confirm_is_guarded_against_double_submit(self): self.assertIn("if (submittingRef.current) return;", self.modal) self.assertIn("submittingRef.current = true;", self.modal) - def test_subclass_is_named_input_not_a_fabricated_dropdown(self): - """The engine does not enumerate world-canon subclasses, so the picker must take a NAMED - subclass (player types it, DM finalizes) — never a hardcoded list the engine doesn't assert.""" + def test_subclass_presents_engine_options_and_keeps_named_input(self): + """#624: the picker presents the engine-exposed SRD subclass options (with previews) when the + planner provides them, AND keeps a named free-text input for world-canon traditions the engine + doesn't enumerate. The options come FROM the planner (option.subclass) — never JSX-hardcoded.""" self.assertIn('data-worldos-testid="levelup-subclass-input"', self.modal) self.assertIn("subclassDue", self.modal) # confirm is blocked until a due subclass is named (honest required-field, not silent default) self.assertIn("subclassDue && !subclassName.trim()", self.modal) + # the options list is sourced from the engine planner option, not a hardcoded JSX array + self.assertIn("option.subclass", self.modal) + self.assertIn('data-worldos-testid="levelup-subclass-options"', self.modal) + # selecting an exposed option fills the named subclass (so confirm relays the chosen name) + self.assertIn("setSubclassName(opt.name)", self.modal) if __name__ == "__main__":