Skip to content

v0.34.0 - Monster Generation

Bugs

  • [x] When a token is selected, the on-hover UI is persisted. This is correct, but ensure the z-index of that UI is not above the Edit Token modal. (The hover/selection overlay — TokenHoverUI action buttons + TokenInfoOverlay name/notes/properties pills — hardcoded zIndex: 10000 (four sites), which floated above the Edit Token modal (MODAL = 2000) and even notifications (5000). Added a dedicated Z_INDEX.TOKEN_HOVER tier (900): above the map chrome so the overlay stays legible, but below context menus (1000) and modals (2000) so it can never cover them. All four sites now use the constant. A source-contract test pins TOKEN_HOVER < MODAL, TOKEN_HOVER < CONTEXT_MENU, and that both components import/use the constant instead of a raw 10000.)
  • [x] No way to deselect a token on mobile. Change as follows:
    • Remove "click to persist the hover UI" behavior on all platforms.
    • On mobile, handle taps specifically. On tap, "select" the token (simulates hover state), on tap-off, "deselect" the token.
    • On all platforms, clicking or tapping a token no longer sets "current selected token" in state. In fact, this state is now obsolete. (Scope note: currentToken is not actually obsolete — it's load-bearing, backing CurrentTokenBar, the TokenContext editor, targeting, VBL vision, and arrow-key movement across ~20 files — so it stays as the under-the-hood single-selection. What changed is what the floating hover UI keys off. Persist-on-select is now mobile-only: persistentHoverToken in UI.tsx short-circuits to null when !isMobile, so on desktop the hover UI (and the bottom-right portrait, which shares the same gate) are purely hover-driven — "click to persist" is gone there. On mobile (no pointer hover) a tap still selects the token and shows its hover UI (anchored to the live SelectedTokenProjector rect), and a plain tap on empty space now deselects: handleTouchEnd in WebGLMap.tsx calls clearSelection() for a no-pan / no-token / no-mode tap, mirroring the desktop empty-space mousedown (~line 1344) that the touch path previously lacked (it only cleared drawings) — which tears the hover UI down. Fixes "no way to deselect on mobile". Source contracts pin the mobile-only gate + memo deps and the touch-deselect; the existing persistent-selection and hover-lifecycle tests stay green.)
  • [x] System reset in admin doesn't work. If I update the system8 wiki, then reset system 8, then go to a character sheet, and load feats/powers, it should show the latest feats/power. Reset should completely invalidate the cache and force reload of all wiki assets. (Reset only reseeded the DB fixture (property defs, conditions, bars, …) — it never touched the two caches that actually hold wiki feats/powers: (1) the server's 24h systemDataCache proxy (advancement.json, feats/powers.json, feat/power detail HTML), and (2) the system8 module's IN-MEMORY state.feats/state.powers/state.equipment, which back the character sheet's feat/power list via /api/modules/system8/feats. Both now invalidate on reset: the reload endpoint (adminSystems.ts) calls systemDataCache.clear() after the reseed, and the admin UI POSTs the module's existing feats/refresh · powers/refresh · system-equipment/refresh endpoints (which call reloadFeats/Powers/Equipment) before clearing the client feat cache and dispatching vtt-system-reloaded. Best-effort Promise.allSettled so a system without those endpoints doesn't fail the reload. No vtt-modules change needed — the module already exposed the refresh routes; they just weren't being called. Source contracts pin both halves; full server-core suite green.)

Misc

  • [x] In the character sheet, add the ability to add or subtract max HP by an arbitrary amount (New MaxHpControl renders a compact "Max HP: N" button under the HP bar in PlayModeView; it opens a popover with an amount NumberInput and Add / Subtract buttons. Persists ${hpKey}_max through the same updateToken({ metadata }) path CurrentTokenBar uses (wired from CharacterContext, which holds updateToken), flooring max at 0 and clamping current HP down if it would exceed the new max. Scoped to token-linked characters whose HP is a range property (System 8 / generic tokens — the hp/hp_max/hp_min model); it renders nothing for attribute-only HP or when no updateToken is supplied. Pure helpers (resolveHpRangeKey, buildMaxHpPatch) are unit-tested; source contracts pin the wiring.)
  • [x] The on-click targeting UI should show for mobile, but not the bottom-right portrait (The targeting UI — TargetingModeBanner / TargetingContextPanel — already renders unconditionally in WebGLMap (no mobile gate), so it shows on mobile. The 20rem×30rem HoverTokenPortrait did not, and at the old z-index it also covered the targeting UI on a phone. Its UI.tsx render condition now also requires !isMobile, so on mobile the targeting/action UI shows and the portrait is suppressed. Source contracts pin both halves.)
  • [x] When the user is focused in the numeric input in the Heal/Harm dialog, and they press H, don't interpret it as text input, but rather as the key shortcut to swap between Heal and Harm mode. (The Amount field is autofocused (v0.33.0), and the global H/M handler in WebGLMap bows out for editable targets (isEditableTarget), so H was just typed into the field. The prompt form's handleFormKeyDown (ActionPromptModal) now delegates H/h/M/m to a new toggleHealHarmMode(form, key) helper that flips the <select name="mode"> between heal/harm and preventDefaults the keystroke. Scoped to forms that actually carry a heal/harm mode select, so it never hijacks typing in other prompt forms. The native select is set directly (no React onChange) so collectFormData's live-DOM read picks up the new mode on submit. Behavioral jsdom tests cover the flip, the M alias, FormData collection, and the no-hijack guard.)
  • [x] Make sure the target token's portrait (bottom right of map view) is behind all modals and dialogs in terms of z-index, so it doesn't accidentally cover up any buttons or inputs. (HoverTokenPortrait hardcoded zIndex: 9000, which floated above every modal/dialog (MODAL=2000, MODAL_OVERLAY=3000) and even notifications (5000). It's a decorative, pointerEvents: 'none' overlay, so it now uses Z_INDEX.MAP_CONTROLS (100) — above the map canvas but behind all interactive chrome. A source-contract test pins the constant and the MAP_CONTROLS < MODAL invariant.)
  • [x] In the map context menu, remove Import Character, Import Party, Insert Monster, Generate Monster, Generate Encounter, and Add Background. Remove these from the Add menu in the Palette, and from the Command Palette (cmd-shift-P). (The six items are re-homed into the "Add to Map" chooser, so they're removed from the three menus: the map context menu (MapContextMenu.tsx !isToken block — items + handlers + the now-orphaned props dropped from the interface and both call sites, WebGLMap.tsx and the Konva Map.tsx), the Palette Add menu (the whole AddToolMenu dropdown was replaced by the chooser-opening button in the chooser commit), and the Command Palette (MapSecondLevelNav.tsx — the generate-monster / generate-encounter / insert-monster / insert-character-as-token commands + their handlers + now-unused store hooks). Generate Map is kept per the plan (not in the removal list), and Create Token / New Token now open the chooser. Newly-unused icon/store imports cleaned up; isolated typecheck dropped 201 → 200. A source contract pins the removals and the kept entries.)

  • [x] When Create Token is invoked, or when the Add button in the Palette is clicked, spawn a modal with the following options: (each has a button with the label given, and a description to the right; buttons are vertically stacked and grouped):

    • Section 1: Basic Tokens
      • Blank Token: an empty token. (Current "Create Token")
      • Background Image: an empty token, except it's pre-set to the Background layer. (Current "Add Background")
    • Section 2: Characters
      • Existing Character: select a Character from the db. (Current "Import Character")
      • Import Character: invokes the Import From PDF flow (pre-existing but hiding in Create Token's sub-options)
      • New Character: makes a new character with a token, impersonates that character, and opens the Character Context to the Build tab. (New feature)
      • Existing Party: select a whole party of Characters from the db. (Current "Import Party")
    • Section 3: Monsters
      • Select Monster from System: pick a monster from the compendium, with its stats/powers/feats as defined in the wiki. (Current "Insert Monster")
      • Generate Monster: use algorithm or AI to make a monster with auto-filled stats, powers, and feats. (Current "Generate Monster")
      • Generate Encounter: use the above repeatedly to build a CR-appropriate encounter. (Current "Generate Encounter")
      • New Monster: create a new Character with a token, impersonate that character, and open the Character Context to the Build tab. (New feature)

    (New CreateTokenChooser modal + createTokenChooserStore, opened by the context menu's Create Token (at the cursor), the Command Palette's New Token, the Palette's Add (+) button, and the Konva map's Create Token — each forwarding the map position. Sections: Basic Tokens (Blank Token → current-layer openTokenModalNew; Background Image → background-layer, GM-only), Characters (Existing Character → setCharacterImportModalOpen; Import Character → the previously-buried PDF flow, surfaced via a new openTokenModalImportSheet / initialImportSheet that opens TokenModal straight to the artwork "Import Character Sheet" view; New Character; Existing Party → setImportPartyModalOpen), Monsters (Select Monster from System → openInsertMonster; Generate Monster; Generate Encounter; New Monster), and Map (Generate Map — kept here so it stays reachable from the Palette now that the Add dropdown is gone). New Character / New Monster share useCreateCharacterToken: create a blank character in the campaign's system, link it to the campaign, drop a linked token (faction party / hostile), impersonate it, and open the Character Context to its Build tab (CharacterContext gained an initialTab prop). Source contracts pin every option's wiring, the PDF-import plumbing, the four entry points, and the create→impersonate→Build-tab sequence; the UI flow itself is smoke-tested interactively.)

  • [x] Reorganize the map-level (empty-space / no-token) right-click context menu into logical groups separated by dividers, the same way the token menu was reorganized in v0.31.0. The map items live in the !isToken block of apps/web/src/components/UI/MapContextMenu/MapContextMenu.tsx (~lines 360-656) and currently render as one flat alphabetical list (the comment at line 359 literally says "sorted alphabetically") — 13 items with no grouping. Use the established divider pattern already present in the token block: {/* ── Group N — Name ── */} markers + <div style={{ borderTop: "1px solid rgba(255, 255, 255, 0.1)", margin: "4px 0" }} /> between groups. Conditional items keep their group slot when shown: Add Light Source (only when showLightSourceOption && onAddLightSource), Paste (only when hasClipboardData && onPasteToken). Proposed order (keeping in mind recently removed items are no longer present):

    • Group 1 — Clipboard (only when clipboard data present): Paste
    • Group 2 — Add to map (manual placement at the cursor): Create Token; Add Drawing Here; Add Light Source
    • Group 3 — Map settings: Edit Map; Edit Grid

    (Done after the item-3 removal trimmed the list to 7. The !isToken block is now three groups with the established {/* ── Group N — Name ── */} markers + the borderTop divider: Clipboard (Paste — its trailing divider only renders when !targetEntity && hasClipboardData && onPasteToken, so an empty clipboard group leaves no stray divider), Add to map (Create Token, Add Drawing Here, Add Light Source — the latter still gated on showLightSourceOption && onAddLightSource), and Map (Edit Map, Edit Grid, Generate Map). Generate Map — kept per the earlier decision — lands in the Map group alongside Edit Map/Edit Grid (the proposed "Map settings" name, broadened to "Map" since it now also holds Generate Map). The "sorted alphabetically" comment is gone. A source contract pins the group order, per-group membership, and the divider pattern.)

Monster Generator

Overhaul of the System 8 algorithmic monster generator into class/role-driven character generation. The items below are broken into atomic, commit-sized TODOs for later implementation; do the Foundations (pure logic + unit tests) first, then wire generation/import, then build the two-mode UI on top.

Architecture change. The generator currently builds a Token directly (apps/web/src/utils/system8MonsterGenerator.ts → tokenDataFromGeneratedMonster in apps/web/src/utils/tokenFromMonster.ts). Change it to build a Character, then run that Character through the existing Character Import path (apps/web/src/utils/characterToToken.ts, the same flow "Insert Monster" / import uses) to produce the Token — so monsters share the PC metadata/actions pipeline and carry real class levels, feats, and powers. UI lives in apps/web/src/components/UI/MonsterGenerator/MonsterGenerator.tsx; class+level persists through the System 8 classLevels[] shape.

Two generation modes, both choosing the monster's name and level (1-20):

  • Template — pick level + a role (Brute, Ravager, Controller, Lurker, Skirmisher, Artillery); the algorithm fills stats/classes/feats/powers from that role.
  • Manual — pick class levels, then either hand-pick feats/powers or have the algorithm fill them from the class+level choices.

Underlying rules — source of truth is the metadata field of https://system8.purplecat.wiki/character/basics/advancement.json (mirrored in the relevant TODOs below for visibility).

Foundations — pure logic + unit tests (UI builds on these)

  • [x] Role model. Add a System 8 role module (the six roles: Brute, Ravager, Controller, Lurker, Skirmisher, Artillery) as data + types, capturing each role's ability-score priority, allowed classes, AC formula, and base speed (the latter three specified in the TODOs below). Ability-score priority per role:
    • Brute: max Str and Con, dump everything else.
    • Ravager: max Str and Dex, decent Con, dump the rest.
    • Controller: max Int, Wis, or Cha (depends on chosen class), decent Con, dump the rest.
    • Lurker: max Dex, decent Con, dump the rest.
    • Skirmisher: max Dex, decent Str and Con, dump the rest.
    • Artillery: max Dex, decent Int, dump the rest. (Note: a MONSTER_CLASS_ATTRIBUTE_FAVOR table with Brute/Skirmisher/Controller/Lurker/Artillerist weights already exists in system8MonsterGenerator.ts — reconcile/replace it with this explicit role model.) (New apps/web/src/utils/system8Roles.ts: SYSTEM8_ROLES + ROLE_DEFINITIONS capture each role's abilityPriority (highest-first, with a 'casting' placeholder resolved per-monster for Controller/Artillery-casters), allowedClasses, formula AC (acBase + acStat), and baseSpeed (6, or 8 for Lurker/Skirmisher). resolveAbilityPriority(role, castingStat?) expands a role's partial priority into a full six-attribute ordering — the base array 16/14/12/12/10/10 is later assigned straight down it. The legacy MONSTER_CLASS_ATTRIBUTE_FAVOR table is left in place until the favor-slider UI is replaced by Template/Manual modes. Unit tests pin the six roles, the AC/speed/allowed-class data, and the casting-stat resolution incl. the no-duplicate guard.)
  • [x] Ability-score generator (underlying logic). Pure function that assigns the six ability scores given a priority order (role template) or explicit choices (manual). Rules: base array 16 14 12 12 10 10; may subtract −2 from a stat zero, one, or two times to add +2 to any stat (cap 16). Level-based proficiency bonuses to stats: two different +1s to chosen stats per level, plus six +1s at level 1; max proficiency bonus per stat = level + 3. Unit-test the caps (base ≤ 16), the 0/1/2 swap counts, and the per-stat prof-bonus maximum. (New apps/web/src/utils/system8AbilityScores.ts. assignBaseScores lays the array down a priority order; applyAbilitySwaps applies 0/1/2 point-neutral −2/+2 swaps — target capped at 16, throws on >2; planPriorityMaxSwaps builds a deterministic plan that maxes a role's top stats without draining below the 8 floor. allocateProficiencyBonuses hands out exactly 2×level+6 points — two different +1s/level plus six at L1 — greedily by priority under the level+3 per-stat cap; generateAbilityScores combines them (Template path), validateAbilityScores checks explicit Manual choices (range, even/point-neutral base total = 74, prof cap + budget). 31 unit tests pin the base≤16 cap, the 0/1/2 swap counts, and the per-stat prof-bonus max.)
  • [x] Class/level advancement model. Pure builder for d20-style advancement: each level either +1 to an existing class or level 1 in a new class; no multiclassing restrictions. Emit the System 8 classLevels[] shape. Tests: class-level total equals monster level; ordering is stable. (New apps/web/src/utils/system8ClassAdvancement.ts: addClassLevel immutably bumps an existing class or appends a level-1 entry; buildClassLevels(picksByLevel) reduces a per-level pick list into CharacterClassLevel[] in stable first-appearance order; totalClassLevel / classNamesFromLevels derive the total level and Character.classes. 11 unit tests pin the total = pick-count invariant, stable ordering, and immutability.)
  • [x] Class roster per role. Map each role to the classes it may auto-pick, and surface the casting-stat choice that drives Controller/Artillery-caster priorities + AC:
    • Brute → always Fighter.
    • Ravager → Fighter or Rogue.
    • Controller → Arcanist (Int or Cha, pick one), Elementalist (Wis), Witch (Int/Wis/Cha), Warlock (Int/Wis/Cha).
    • Lurker → Rogue.
    • Skirmisher → Fighter or Rogue.
    • Artillery → choose flavor (caster vs archer): caster = Arcanist (Int or Cha) / Elementalist (Wis) / Witch (Int/Wis/Cha) / Warlock (Int/Wis/Cha); archer = Fighter. (Added to system8Roles.ts: ClassOption (className + allowed castingStats), CASTER_CLASS_OPTIONS, and ROLE_CLASS_ROSTER per role. rosterForRole(role, flavor?) narrows Artillery to caster (the four casters) or archer (Fighter); roleNeedsCastingStat, castingStatsForClass, isValidCastingStat, and defaultCastingStatForClass surface and validate the casting-stat pick. A test pins roster ⇄ allowedClasses consistency. 22 role tests total.)
  • [x] Power auto-fill logic. Pure function over the loaded powers list (GET /api/modules/system8/powers); rank = floor(level / 4) + 1 (NOTE: differs from the existing rankForLevel in system8MonsterGenerator.ts, which uses rankLevels [5,9,13,17] — reconcile during implementation).
    • Template (Brute, Ravager, Lurker, Skirmisher, Artillery-archer): exclusively Monster or Heroic powers. Heroic powers should match the best stats (e.g. Heroic Strength for Brutes). Monster powers: choose randomly for now (per-power role weights are a future system).
    • Manual auto-fill: for each power slot, weight classes by their level share (Warlock 5 / Fighter 5 → 50:50); pick a class by weight; for each class with ≥1 level pick a random power school/category (e.g. Death Knight for Fighter, Infernal for Warlock); from that class's powers at the slot's rank pick a random power in the chosen school/category; if none exist for that school/category at that rank, pick any power of that class+rank. Test with injected power data (deterministic seed), mirroring system8MonsterGenerator.test.ts. (New apps/web/src/utils/system8PowerAutoFill.ts (+ shared seededRandom.ts mulberry32). rankForLevel = floor(level/4)+1 replaces the legacy rankLevels version; powerSlotLevels/powerSlotCount derive slots from the advancement metadata (power_levels + bonus L1 slot). pickTemplatePowerForSlot returns only Monster/Heroic powers, preferring best-stat-matching Heroic + random Monster; pickManualPowerForSlot weights classes by level share (weightedClassByLevel), picks a class, a random school at the slot's rank, then a random power — falling back to any class+rank power. autoFillTemplatePowers/autoFillManualPowers fill every slot without duplicates. 16 seeded unit tests over injected power data.)
  • [x] Feat auto-fill logic. Pure function over the loaded feats list (GET /api/modules/system8/feats): for each feat slot, weight classes as above, pick a class by weight, then pick a random feat available to that class+rank; if none, pick any random feat. Used by Template mode and by Manual mode's automatic-fill. Deterministic-seed tests. (New apps/web/src/utils/system8FeatAutoFill.ts. featSlotLevels/featSlotCount derive feat slots from the advancement metadata (feat_levels + 3 bonus L1 feats). pickFeatForSlot reuses weightedClassByLevel/rankForLevel, picks a class then a random class+rank feat, falling back to any feat (skill/background/ancestry are not class-locked). autoFillFeats fills every slot without duplicates. 7 seeded unit tests.)

Character generation & Character Import wiring

  • [x] Generate a Character, not a Token. Assemble a System 8 Character from {name, level, ability scores, classLevels[], feats, powers, derived HP/AC/speed} using the foundation functions, replacing the current token-first assembly. (New apps/web/src/utils/system8CharacterGenerator.ts. generateTemplateCharacter picks a class from the role roster, resolves the casting stat, rolls ability scores by role priority, auto-fills Monster/Heroic powers (martials) or class powers (casters) + feats, derives AC/speed, and assembles a Character (six ability attributes as base+proficiency components, AC as an "Armor Class" attribute → metadata.ac, classLevels[], systemId: 'system8'). assembleManualCharacter does the same from explicit pieces (AC = 10 + highest mod + adjustment). Returns { character, derived: { hitPoints, armorClass, speed, sizeSquares, sizeCategory } } for preview + token shaping. 15 seeded unit tests.)
  • [x] Route the generated Character through Character Import. Feed the generated Character into the existing import pipeline (characterToToken.ts) so the Token is produced exactly like an imported PC. Verify metadata + actions parity with the old direct-token output (regression test). (New apps/web/src/utils/tokenFromGeneratedCharacter.ts: tokenDataFromGeneratedCharacter runs the Character through convertCharacterToToken (yielding the PC-style ability/skill/power/reaction actions + ability/AC/level metadata), then layers on the monster's derived HP (System 8 5 + Con, overridden here so existing PC imports keep the converter's generic 8 + Con), speed, Large/Huge size (token 2×2 / 3×3), faction: hostile, and power/feat name lists. Also added the missing systemId? to the converter's local Character interface and faction?: TokenFaction to IToken (fixing a pre-existing baseline type error in tokenFromMonster.ts). 5 regression tests pin the token's abilities/AC/level/HP/size/actions; the 50 existing converter tests stay green.)

Template mode (UI)

  • [x] Template/Manual mode toggle in MonsterGenerator.tsx (default Template). (Added a system8Mode state ('template' | 'manual', default Template) and a Mantine SegmentedControl shown for System 8 campaigns. The legacy favor-slider config now renders only in Template mode; Manual mode shows a placeholder panel until its editors land (Manual-mode TODOs). Source-contract test pins the toggle, default, and the mode gates.)
  • [x] Template controls: name input, level (1-20 NumberInput — already exists), role select (six roles). Artillery exposes a caster/archer flavor choice; casters expose the casting-stat pick. On generate, run the foundation logic to fill stats/classes/feats/powers/derived stats. (Template mode adds a role Select (six roles), an Artillery flavor Select (archer/caster, shown only for Artillery), and a casting-stat Select (Int/Wis/Cha, shown only when roleNeedsCastingStat). Generate calls generateTemplateCharacter with the loaded powers/feats + the fetched advancement.json metadata (a new effect fetches it via the system-data proxy, falling back to the bundled fixture); the result previews ability scores/AC/HP/speed/powers/feats, and Insert/Customize build the token via tokenDataFromGeneratedCharacter (the Character Import path). The legacy favor sliders moved to Manual mode (the interim old-generator path) until the Manual editors land. Source-contract test pins the controls and the generate/create wiring; all 38 MonsterGenerator tests stay green.)

Manual mode (UI)

  • [x] Manual ability scores: per stat, a numeric input for base value and a second for proficiency bonus, each flanked by + / − and max / min buttons. max/min base = 16 / 8; max prof bonus = level + 3 (given the class/level choices). Maxing a base stat to 16 swaps the current 16 for the maxed stat's previous value. Show a validation error until the stats satisfy the underlying-logic rules above. (New System8AbilityEditor.tsx renders six stat rows (Base + Prof NumberInputs, each with −/+ and Min/Max buttons) backed by pure helpers maxBaseStat (the point-neutral 16-swap), minBaseStat, stepBaseStat (clamp 8–16), stepProficiency (clamp 0–level+3), plus a validateAbilityScores banner. Wired into Manual mode (the legacy favor sliders moved behind an opt-in toggle). Helpers unit-tested; the editor + wiring source-contract-tested. Generation from these values lands with the Manual class rows (next TODO).)
  • [x] Manual class/level rows: one row = class dropdown + level numeric input (with a max button); an "add class" button appends rows. Validation error until the class-level total equals the monster level. (New System8ClassLevelEditor.tsx: one row per class (a class Select over SYSTEM8_CLASS_NAMES + a level NumberInput with a Max button that absorbs the remaining levels + a remove button), an "Add class" button, and a totalClassLevel vs monster-level validation banner. Wired into Manual mode, and Manual-mode Generate now runs handleGenerateManual → assembleManualCharacter from the hand-edited ability scores + class levels, auto-filling powers/feats from the classes (validating ability scores + class total first). The legacy favor generator stays reachable via the opt-in toggle. Source-contract test pins the editor and generation wiring.)
  • [x] Manual powers UI: render the current Character Build power-slot UI with slots empty; clicking a row opens the existing power-select modal (reuse it); add a per-row randomize button (re-roll that slot via the power auto-fill logic) and a "fill all powers" button that fills every empty slot. (New System8ManualPowersEditor.tsx renders the monster's power slots (count from the advancement metadata) starting empty; the empty-slot button opens the reused AddPowerModal (the Character Build power-select modal from @purple-cat/ui-components), each filled slot has a per-row Randomize button (pickManualPowerForSlot) and a Remove, and a "Fill all powers" button runs autoFillManualPowers. Wired into Manual mode; Manual-mode Generate uses the hand-picked powers when present, else auto-fills. Source-contract tested.)
  • [x] Manual feats UI: same as powers — empty feat slots, reuse the existing feat-select modal, a per-row randomize button, and a "fill all feats" button. (New System8ManualFeatsEditor.tsx mirrors the powers editor: empty feat slots (count from the advancement metadata), the reused AddFeatModal (systemId: 'system8') for hand-picking, a per-row Randomize (pickFeatForSlot) + Remove, and a "Fill all feats" button (autoFillFeats). Wired into Manual mode; Manual-mode Generate uses the hand-picked feats when present, else auto-fills. Source-contract tested.)

Derived stats: HP, AC, Speed, token size

  • [x] Hit Points: HP per level = 5 + Con for all characters/monsters (kept simple for now; role may factor in later). Reconcile with the current formula max(1, 10 + 5*(level-1) + ConMod*level) in system8MonsterGenerator.ts. (New apps/web/src/utils/system8DerivedStats.ts: computeHitPoints({ constitution, level }) = max(1, level × (5 + ConMod)) where ConMod = floor((con − 10) / 2) and con is the total score — replacing the legacy formula. 4 unit tests.)
  • [x] Armor Class (no equipment — formula-based).
    • Template by role: Brute = 14 + Con mod; Ravager = 12 + Dex mod; Controller = 10 + casting-stat mod; Lurker = 10 + Dex mod; Skirmisher = 12 + Dex mod; Artillery = 10 + Dex mod (archer) or 10 + casting-stat mod (caster).
    • Manual: AC = 10 + highest stat mod, plus a user-adjustable −4..+4 to represent armor and other factors. (Added to system8DerivedStats.ts: computeTemplateArmorClass({ role, scores, castingStat?, artilleryFlavor? }) reads acBase/acStat from ROLE_DEFINITIONS (Artillery-caster swaps Dex→casting); computeManualArmorClass({ scores, adjustment }) = 10 + highest mod + clamp(adjustment, −4, +4) via MANUAL_AC_ADJUSTMENT_MIN/MAX. 11 unit tests cover every role + the manual clamp.)
  • [x] Speed & token size: base 6; Lurkers and Skirmishers base 8. If the power Large is chosen: +2 speed and size the token to 2x2. If the power Huge is chosen: +4 speed and size the token to 3x3. (Added to system8DerivedStats.ts: baseSpeedForRole (6, or 8 for Lurker/Skirmisher from the role model) and computeSpeedAndSize({ baseSpeed, powers }) → { speed, sizeSquares, sizeCategory }, applying Large (+2, 2×2) / Huge (+4, 3×3) when the matching power is present (Huge wins if both), else Medium 1×1. 7 unit tests.)

Equipment

  • [x] Stub equipment — skip equipment generation entirely for now (concern: every enemy carrying PC-tier magic items wrecks the treasure economy). AC is handled by the formulas above, not items. (The Character assembler generates no equipment by construction — attacks (which would carry weapons) is left empty and no equipment/items field is populated; AC comes from the role/manual formula. Documented at the assembly site and pinned by a guard test.)