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 —
TokenHoverUIaction buttons +TokenInfoOverlayname/notes/properties pills — hardcodedzIndex: 10000(four sites), which floated above the Edit Token modal (MODAL= 2000) and even notifications (5000). Added a dedicatedZ_INDEX.TOKEN_HOVERtier (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 pinsTOKEN_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:
currentTokenis not actually obsolete — it's load-bearing, backingCurrentTokenBar, theTokenContexteditor, 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:persistentHoverTokeninUI.tsxshort-circuits tonullwhen!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 liveSelectedTokenProjectorrect), and a plain tap on empty space now deselects:handleTouchEndinWebGLMap.tsxcallsclearSelection()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
systemDataCacheproxy (advancement.json, feats/powers.json, feat/power detail HTML), and (2) the system8 module's IN-MEMORYstate.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) callssystemDataCache.clear()after the reseed, and the admin UI POSTs the module's existingfeats/refresh·powers/refresh·system-equipment/refreshendpoints (which callreloadFeats/Powers/Equipment) before clearing the client feat cache and dispatchingvtt-system-reloaded. Best-effortPromise.allSettledso a system without those endpoints doesn't fail the reload. Novtt-moduleschange 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
MaxHpControlrenders a compact "Max HP: N" button under the HP bar inPlayModeView; it opens a popover with an amountNumberInputand Add / Subtract buttons. Persists${hpKey}_maxthrough the sameupdateToken({ metadata })pathCurrentTokenBaruses (wired fromCharacterContext, which holdsupdateToken), flooring max at 0 and clamping current HP down if it would exceed the new max. Scoped to token-linked characters whose HP is arangeproperty (System 8 / generic tokens — thehp/hp_max/hp_minmodel); it renders nothing for attribute-only HP or when noupdateTokenis 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 inWebGLMap(no mobile gate), so it shows on mobile. The 20rem×30remHoverTokenPortraitdid 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'shandleFormKeyDown(ActionPromptModal) now delegates H/h/M/m to a newtoggleHealHarmMode(form, key)helper that flips the<select name="mode">between heal/harm andpreventDefaults 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) socollectFormData'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. (
HoverTokenPortraithardcodedzIndex: 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 usesZ_INDEX.MAP_CONTROLS(100) — above the map canvas but behind all interactive chrome. A source-contract test pins the constant and theMAP_CONTROLS < MODALinvariant.)
Menu
-
[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!isTokenblock — items + handlers + the now-orphaned props dropped from the interface and both call sites,WebGLMap.tsxand the KonvaMap.tsx), the Palette Add menu (the wholeAddToolMenudropdown was replaced by the chooser-opening button in the chooser commit), and the Command Palette (MapSecondLevelNav.tsx— thegenerate-monster/generate-encounter/insert-monster/insert-character-as-tokencommands + 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
CreateTokenChoosermodal +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-layeropenTokenModalNew; Background Image → background-layer, GM-only), Characters (Existing Character →setCharacterImportModalOpen; Import Character → the previously-buried PDF flow, surfaced via a newopenTokenModalImportSheet/initialImportSheetthat 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 shareuseCreateCharacterToken: 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 aninitialTabprop). 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.) - Section 1: Basic Tokens
-
[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
!isTokenblock ofapps/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 whenshowLightSourceOption && onAddLightSource), Paste (only whenhasClipboardData && 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
!isTokenblock is now three groups with the established{/* ── Group N — Name ── */}markers + theborderTopdivider: 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 onshowLightSourceOption && 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_FAVORtable with Brute/Skirmisher/Controller/Lurker/Artillerist weights already exists insystem8MonsterGenerator.ts— reconcile/replace it with this explicit role model.) (Newapps/web/src/utils/system8Roles.ts:SYSTEM8_ROLES+ROLE_DEFINITIONScapture each role'sabilityPriority(highest-first, with a'casting'placeholder resolved per-monster for Controller/Artillery-casters),allowedClasses, formula AC (acBase+acStat), andbaseSpeed(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 legacyMONSTER_CLASS_ATTRIBUTE_FAVORtable 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. (Newapps/web/src/utils/system8AbilityScores.ts.assignBaseScoreslays the array down a priority order;applyAbilitySwapsapplies 0/1/2 point-neutral −2/+2 swaps — target capped at 16, throws on >2;planPriorityMaxSwapsbuilds a deterministic plan that maxes a role's top stats without draining below the 8 floor.allocateProficiencyBonuseshands out exactly2×level+6points — two different +1s/level plus six at L1 — greedily by priority under thelevel+3per-stat cap;generateAbilityScorescombines them (Template path),validateAbilityScoreschecks 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. (Newapps/web/src/utils/system8ClassAdvancement.ts:addClassLevelimmutably bumps an existing class or appends a level-1 entry;buildClassLevels(picksByLevel)reduces a per-level pick list intoCharacterClassLevel[]in stable first-appearance order;totalClassLevel/classNamesFromLevelsderive the total level andCharacter.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+ allowedcastingStats),CASTER_CLASS_OPTIONS, andROLE_CLASS_ROSTERper role.rosterForRole(role, flavor?)narrows Artillery to caster (the four casters) or archer (Fighter);roleNeedsCastingStat,castingStatsForClass,isValidCastingStat, anddefaultCastingStatForClasssurface and validate the casting-stat pick. A test pins roster ⇄allowedClassesconsistency. 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 existingrankForLevelinsystem8MonsterGenerator.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. (Newapps/web/src/utils/system8PowerAutoFill.ts(+ sharedseededRandom.tsmulberry32).rankForLevel = floor(level/4)+1replaces the legacy rankLevels version;powerSlotLevels/powerSlotCountderive slots from the advancement metadata (power_levels + bonus L1 slot).pickTemplatePowerForSlotreturns only Monster/Heroic powers, preferring best-stat-matching Heroic + random Monster;pickManualPowerForSlotweights 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/autoFillManualPowersfill 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. (Newapps/web/src/utils/system8FeatAutoFill.ts.featSlotLevels/featSlotCountderive feat slots from the advancement metadata (feat_levels + 3 bonus L1 feats).pickFeatForSlotreusesweightedClassByLevel/rankForLevel, picks a class then a random class+rank feat, falling back to any feat (skill/background/ancestry are not class-locked).autoFillFeatsfills 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. (Newapps/web/src/utils/system8CharacterGenerator.ts.generateTemplateCharacterpicks 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 aCharacter(six ability attributes as base+proficiency components, AC as an "Armor Class" attribute →metadata.ac,classLevels[],systemId: 'system8').assembleManualCharacterdoes 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). (Newapps/web/src/utils/tokenFromGeneratedCharacter.ts:tokenDataFromGeneratedCharacterruns the Character throughconvertCharacterToToken(yielding the PC-style ability/skill/power/reaction actions + ability/AC/level metadata), then layers on the monster's derived HP (System 85 + Con, overridden here so existing PC imports keep the converter's generic8 + Con), speed, Large/Huge size (token 2×2 / 3×3),faction: hostile, and power/feat name lists. Also added the missingsystemId?to the converter's local Character interface andfaction?: TokenFactiontoIToken(fixing a pre-existing baseline type error intokenFromMonster.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 asystem8Modestate ('template' | 'manual', default Template) and a MantineSegmentedControlshown 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 flavorSelect(archer/caster, shown only for Artillery), and a casting-statSelect(Int/Wis/Cha, shown only whenroleNeedsCastingStat). Generate callsgenerateTemplateCharacterwith 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 viatokenDataFromGeneratedCharacter(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. (NewSystem8AbilityEditor.tsxrenders six stat rows (Base + ProfNumberInputs, each with −/+ and Min/Max buttons) backed by pure helpersmaxBaseStat(the point-neutral 16-swap),minBaseStat,stepBaseStat(clamp 8–16),stepProficiency(clamp 0–level+3), plus avalidateAbilityScoresbanner. 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 classSelectoverSYSTEM8_CLASS_NAMES+ a levelNumberInputwith a Max button that absorbs the remaining levels + a remove button), an "Add class" button, and atotalClassLevelvs monster-level validation banner. Wired into Manual mode, and Manual-mode Generate now runshandleGenerateManual→assembleManualCharacterfrom 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.tsxrenders the monster's power slots (count from the advancement metadata) starting empty; the empty-slot button opens the reusedAddPowerModal(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 runsautoFillManualPowers. 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.tsxmirrors the powers editor: empty feat slots (count from the advancement metadata), the reusedAddFeatModal(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 + Confor all characters/monsters (kept simple for now; role may factor in later). Reconcile with the current formulamax(1, 10 + 5*(level-1) + ConMod*level)insystem8MonsterGenerator.ts. (Newapps/web/src/utils/system8DerivedStats.ts:computeHitPoints({ constitution, level })=max(1, level × (5 + ConMod))whereConMod = floor((con − 10) / 2)andconis 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? })readsacBase/acStatfromROLE_DEFINITIONS(Artillery-caster swaps Dex→casting);computeManualArmorClass({ scores, adjustment })=10 + highest mod + clamp(adjustment, −4, +4)viaMANUAL_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) andcomputeSpeedAndSize({ 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.)