feat: Dice Roller — player sheet auto-load, weapon filtering, condition modifiers

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 00:38:32 +02:00
parent cdfd147f02
commit 8febe583ee
2 changed files with 124 additions and 7 deletions

View File

@@ -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 : <div dangerouslySetInnerHTML={{ __html: info }} />}
</div>
)}
{myConditions.length > 0 && (
<div className="rounded-xl border border-rose-700/50 bg-rose-950/30 px-4 py-2 flex flex-wrap items-center gap-3">
<span className="text-xs font-semibold uppercase tracking-wide text-rose-300">Conditions active</span>
{myConditions.map(cond => (
<span key={cond} className="rounded bg-rose-800/60 px-2 py-0.5 text-xs text-rose-100">
{CONDITION_MODS[cond]?.label || cond}
</span>
))}
{conditionMod !== 0 && (
<span className="ml-auto text-xs text-rose-200 font-semibold">
{conditionMod > 0 ? '+' : ''}{conditionMod} to attack target
</span>
)}
</div>
)}
<div className="flex items-center justify-between">
<h1 className="text-3xl md:text-4xl font-extrabold tracking-tight">Deathwatch Roller Pro</h1>
<div className="flex items-center gap-3">
{combatRound?.active && (
<span className="text-xs px-2 py-1 rounded bg-red-700 text-red-100 font-bold">Round {combatRound.round}</span>
)}
<div className="text-xs uppercase tracking-wide opacity-70">Target {target} {usingSkill}</div>
<div className="text-xs uppercase tracking-wide opacity-70">
Target {target} {usingSkill}
{conditionMod !== 0 && <span className="text-rose-300 ml-1">({conditionMod > 0 ? '+' : ''}{conditionMod} cond)</span>}
</div>
<span className={`text-xs px-2 py-1 rounded ${statusColor}`}>{status}</span>
<button
onClick={() => setInfo(`

View File

@@ -320,11 +320,16 @@ export default function MissionTab({ authedPlayer }) {
// ─── Combat state → localStorage sync (#4) ──────────────────────
useEffect(() => {
try {
const state = { round: combatState.round, fearRating: combatState.fearRating, active: combatActive };
const state = {
round: combatState.round,
fearRating: combatState.fearRating,
active: combatActive,
conditions: combatState.conditions,
};
window.localStorage.setItem(COMBAT_STATE_KEY, JSON.stringify(state));
window.dispatchEvent(new CustomEvent('dw:combat-state', { detail: state }));
} catch {}
}, [combatState.round, combatState.fearRating, combatActive]);
}, [combatState.round, combatState.fearRating, combatActive, combatState.conditions]);
// ─── Server mutations ────────────────────────────────────────────