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:
@@ -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(`
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user