From 8febe583eef1c58fda6533cba11ed059024b9511 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 24 Jun 2026 00:38:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Dice=20Roller=20=E2=80=94=20player=20sh?= =?UTF-8?q?eet=20auto-load,=20weapon=20filtering,=20condition=20modifiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On load, each player's character sheet is fetched from /api/players/{name}: - BS and WS set from tabInfo.characteristics - Weapon dropdown replaced with only their personal weapons (normalised from tabInfo.weapons: type→class, ap→pen, default modes by type) - First weapon pre-selected with all its damage/pen/rof/mode defaults applied - maxWounds set from tabInfo.wounds; curWounds preserved from localStorage if the player was already tracking mid-session Active scene conditions (synced from MissionTab via dw:combat-state) now apply automatic attack modifiers: - Pinned: –20 BS - Stunned: –20 BS/WS - Knocked Down: –20 BS/WS - Fatigued: –10 BS/WS - Grappled: –20 WS, ranged/heavy weapons hidden from weapon dropdown - Bleeding: banner only (no roll modifier) Condition banner appears above the roller when any condition is active, showing each condition label and the net modifier to the attack target. The status bar target display also shows the condition delta inline. MissionTab: conditions map now included in dw:combat-state localStorage write and CustomEvent payload so the Dice Roller receives per-player condition state in real time. Co-Authored-By: Claude Sonnet 4.6 --- src/components/DeathwatchRoller.jsx | 122 ++++++++++++++++++++++++++-- src/components/MissionTab.jsx | 9 +- 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/src/components/DeathwatchRoller.jsx b/src/components/DeathwatchRoller.jsx index 479b72f..69b6d47 100755 --- a/src/components/DeathwatchRoller.jsx +++ b/src/components/DeathwatchRoller.jsx @@ -99,6 +99,34 @@ const STORAGE_HISTORY = 'dw:history:v2' const STORAGE_WEAPONS = 'dw:weapons:v3' const STORAGE_TRACKER = 'dw:tracker:v1' +// Condition → attack modifier (Deathwatch core rules) +const CONDITION_MODS = { + Pinned: { bs: -20, ws: 0, label: 'Pinned (–20 BS)' }, + Stunned: { bs: -20, ws: -20, label: 'Stunned (–20)' }, + 'Knocked Down': { bs: -20, ws: -20, label: 'Knocked Down (–20)' }, + Fatigued: { bs: -10, ws: -10, label: 'Fatigued (–10)' }, + Grappled: { bs: 0, ws: -20, label: 'Grappled (–20 WS, ranged blocked)' }, + Bleeding: { bs: 0, ws: 0, label: 'Bleeding (1 wound/turn)' }, +} + +function mapPlayerWeapon(w) { + const type = String(w.type || w.class || '').toLowerCase() + const clsMap = { heavy: 'heavy', pistol: 'pistol', basic: 'basic', melee: 'melee', bolt: 'basic', bolter: 'basic' } + const cls = clsMap[type] || (type.includes('pistol') ? 'pistol' : type.includes('melee') || type.includes('sword') || type.includes('axe') || type.includes('fist') || type.includes('hammer') ? 'melee' : 'basic') + const modesDefault = cls === 'melee' ? ['single'] : cls === 'pistol' ? ['single'] : cls === 'heavy' ? ['single'] : ['single', 'semi'] + return normalizeWeapon({ + name: w.name, + class: cls, + damage: w.damage || '1d10', + pen: w.pen != null ? +w.pen : (w.ap != null ? Math.abs(+w.ap) : 0), + rof: w.rof || 1, + modes: Array.isArray(w.modes) && w.modes.length ? w.modes : modesDefault, + tearing: !!w.tearing, + reliable: !!w.reliable, + proven: w.proven || 0, + }) +} + const BODY_PARTS = ['Head','Body','Left Arm','Right Arm','Left Leg','Right Leg'] // Note: don't require files outside `src/` (CRA will fail the production build). @@ -460,7 +488,27 @@ function DeathwatchRoller({ authedPlayer }) { const usingSkill = useMemo(()=>{ const w = weapons.find(x=>x.name===weaponName); if (!w) return 'BS'; return w.class==='melee' ? 'WS' : 'BS' },[weaponName, weapons]) const diffMod = useMemo(()=>{ if (difficulty==='easy') return 20; if (difficulty==='hard') return -20; if (difficulty==='deadly') return -30; return 0 },[difficulty]) const baseSkill = useMemo(()=> usingSkill === 'BS' ? bs : ws, [usingSkill, bs, ws]) - const target = useMemo(()=> Math.max(0, Math.min(100, baseSkill + modifier + aim + diffMod)), [baseSkill, modifier, aim, diffMod]) + + // Active conditions for this player, read from combat-state localStorage/event + const myConditions = useMemo(() => { + if (!authedPlayer || authedPlayer === 'gm') return [] + return (combatRound?.conditions || {})[authedPlayer] || [] + }, [combatRound, authedPlayer]) + + const conditionMod = useMemo(() => { + let mod = 0 + for (const cond of myConditions) { + const entry = CONDITION_MODS[cond] + if (!entry) continue + mod += usingSkill === 'WS' ? entry.ws : entry.bs + } + return mod + }, [myConditions, usingSkill]) + + // Grappled blocks ranged weapons + const isGrappled = myConditions.includes('Grappled') + + const target = useMemo(()=> Math.max(0, Math.min(100, baseSkill + modifier + aim + diffMod + conditionMod)), [baseSkill, modifier, aim, diffMod, conditionMod]) useEffect(()=>{ safeSet(STORAGE_HISTORY, history) },[history]) useEffect(()=>{ safeSet(STORAGE_PRESETS, presets) },[presets]) @@ -486,6 +534,49 @@ function DeathwatchRoller({ authedPlayer }) { return () => window.removeEventListener('dw:combat-state', onCombatState) }, []) + // Load player character sheet on mount — sets BS/WS/wounds and replaces weapon list + useEffect(() => { + if (!authedPlayer || authedPlayer === 'gm') return + ;(async () => { + try { + const res = await fetch(`/api/players/${authedPlayer}`, { cache: 'no-store' }) + if (!res.ok) return + const player = await res.json() + const tab = player.tabInfo || {} + const chars = tab.characteristics || {} + + if (typeof chars.BS === 'number') setBS(chars.BS) + if (typeof chars.WS === 'number') setWS(chars.WS) + if (typeof chars.Ag === 'number') { + // Set defender agility as own Ag for dodge calculations + setDefenderAg(chars.Ag) + } + + const sheetWounds = tab.wounds + if (typeof sheetWounds === 'number' && sheetWounds > 0) { + setMaxWounds(sheetWounds) + // Only reset curWounds if tracker wasn't already customised this session + const stored = safeGet(STORAGE_TRACKER) + if (!stored || stored.maxWounds !== sheetWounds) setCurWounds(sheetWounds) + } + + const playerWeapons = Array.isArray(tab.weapons) ? tab.weapons.map(mapPlayerWeapon).filter(w => w.name) : [] + if (playerWeapons.length > 0) { + setWeapons(playerWeapons) + // Pre-select first weapon and apply its defaults + const first = playerWeapons[0] + setWeaponName(first.name) + const d = computeWeaponDefaults(first) + setDamage(d.damage); setTearing(d.tearing); setProven(d.proven) + setPen(d.pen); setReliable(d.reliable); setRof(d.rof); setMode(d.mode) + } + } catch (e) { + console.info('[DW] Player sheet load failed', e && e.message) + } + })() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authedPlayer]) + // On first run, if there are no stored weapons try to fetch the packaged DB from public useEffect(()=>{ (async () => { @@ -528,9 +619,12 @@ function DeathwatchRoller({ authedPlayer }) { const filteredWeapons = useMemo(()=>{ const q = weaponFilter.trim().toLowerCase() - if (!q) return weapons - return weapons.filter(w => w.name.toLowerCase().includes(q)) - },[weapons, weaponFilter]) + let list = weapons + // Grappled: can only use melee/pistol while in close combat + if (isGrappled) list = list.filter(w => w.class === 'melee' || w.class === 'pistol') + if (!q) return list + return list.filter(w => w.name.toLowerCase().includes(q)) + },[weapons, weaponFilter, isGrappled]) function onSelectWeapon(name) { setWeaponName(name) @@ -988,13 +1082,31 @@ function DeathwatchRoller({ authedPlayer }) { {error ? error :
}
)} + {myConditions.length > 0 && ( +
+ Conditions active + {myConditions.map(cond => ( + + {CONDITION_MODS[cond]?.label || cond} + + ))} + {conditionMod !== 0 && ( + + {conditionMod > 0 ? '+' : ''}{conditionMod} to attack target + + )} +
+ )}

Deathwatch Roller Pro

{combatRound?.active && ( Round {combatRound.round} )} -
Target {target} • {usingSkill}
+
+ Target {target} • {usingSkill} + {conditionMod !== 0 && ({conditionMod > 0 ? '+' : ''}{conditionMod} cond)} +
{status}