Split roller into Skill and Combat tabs

Extract the skill-check UI/logic into a standalone SkillRoller component
and move shared dice helpers (d100, degrees, uid) into rollHelpers.js.
DeathwatchRoller now hosts the shared roll history + player sheet and a
Combat/Skill tab toggle; the skill-only characteristic/training helpers
moved into SkillRoller. Behaviour unchanged, including first-skill
preselect (now done inside SkillRoller) and the manual target override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 12:30:03 +02:00
parent d3305b957e
commit 36378ff702
3 changed files with 197 additions and 172 deletions

View File

@@ -1,5 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { MISSION_ROLL_CONTEXT_KEY } from '../utils/diceRoller';
import SkillRoller from './SkillRoller';
import { d100, degrees, uid } from './rollHelpers';
// Tooltip component for abbreviations
function Tooltip({ children, text }) {
@@ -77,14 +79,6 @@ function rollDice(terms, opts) {
return { rolls: out, total: out.reduce((a,b)=>a+b,0) }
}
function d100() { return Math.floor(Math.random()*100)+1 }
function degrees(target, roll) {
const success = roll <= target
if (success) { const diff = target - roll; const dos = 1 + Math.floor(diff/10); return { success, dos, dof: 0 } }
const diff = roll - target; const dof = 1 + Math.floor(diff/10); return { success, dos: 0, dof }
}
function hitsFromDoS(mode, dos, rof) {
const r = rof && rof>0 ? rof : 1
if (mode === 'single') return Math.min(1, r)
@@ -371,58 +365,6 @@ function normalizeWeapon(w) {
function computeWeaponDefaults(w) { return { damage: w.damage, tearing: !!w.tearing, proven: w.proven || 0, pen: w.pen || 0, reliable: !!w.reliable, rof: w.rof || 1, mode: (w.modes && w.modes[0]) || 'single' } }
function uid() { return Math.random().toString(36).slice(2) + Date.now().toString(36) }
const CHARACTERISTIC_ALIASES = {
WS: ['WS', 'Ws', 'ws'],
BS: ['BS', 'Bs', 'bs'],
S: ['S', 's'],
T: ['T', 't'],
Ag: ['Ag', 'AG', 'ag'],
Int: ['Int', 'INT', 'int'],
Per: ['Per', 'PER', 'per'],
WP: ['WP', 'Wp', 'wp'],
Wp: ['WP', 'Wp', 'wp'],
Fel: ['Fel', 'FEL', 'fel'],
}
function characteristicFromSkillName(name) {
const match = String(name || '').match(/\(([^)]+)\)/)
const raw = match ? match[1].trim() : 'Int'
if (/^wp$/i.test(raw)) return 'WP'
if (/^ag$/i.test(raw)) return 'Ag'
if (/^int$/i.test(raw)) return 'Int'
if (/^per$/i.test(raw)) return 'Per'
if (/^fel$/i.test(raw)) return 'Fel'
if (/^bs$/i.test(raw)) return 'BS'
if (/^ws$/i.test(raw)) return 'WS'
if (/^s$/i.test(raw)) return 'S'
if (/^t$/i.test(raw)) return 'T'
return raw
}
function characteristicValue(chars, key, fallback = 45) {
const aliases = CHARACTERISTIC_ALIASES[key] || [key]
for (const alias of aliases) {
if (typeof chars?.[alias] === 'number') return chars[alias]
}
return fallback
}
function skillTrainingBonus(skill) {
if (skill?.plus20) return 20
if (skill?.plus10) return 10
if (skill?.trained) return 0
return -20
}
function skillTrainingLabel(skill) {
if (skill?.plus20) return 'Trained +20'
if (skill?.plus10) return 'Trained +10'
if (skill?.trained) return 'Trained'
return 'Untrained -20'
}
function DeathwatchRoller({ authedPlayer }) {
const [bs,setBS] = useState(45)
const [ws,setWS] = useState(45)
@@ -463,10 +405,7 @@ function DeathwatchRoller({ authedPlayer }) {
const [attackerName,setAttackerName] = useState('Custom/None')
const [playerSkills, setPlayerSkills] = useState({})
const [playerCharacteristics, setPlayerCharacteristics] = useState({})
const [skillRollName, setSkillRollName] = useState('')
const [skillRollModifier, setSkillRollModifier] = useState(0)
const [manualSkillRoll, setManualSkillRoll] = useState('')
const [skillTargetOverride, setSkillTargetOverride] = useState('')
const [rollerTab, setRollerTab] = useState('combat')
// Load enemies from database API
async function loadEnemiesFromAPI() {
@@ -666,12 +605,6 @@ function DeathwatchRoller({ authedPlayer }) {
const sheetSkills = tab.skills || {}
setPlayerCharacteristics(chars)
setPlayerSkills(sheetSkills)
const skillNames = Object.keys(sheetSkills).sort((a, b) => {
const at = sheetSkills[a]?.trained ? 0 : 1
const bt = sheetSkills[b]?.trained ? 0 : 1
return at - bt || a.localeCompare(b)
})
if (skillNames.length > 0) setSkillRollName(prev => prev || skillNames[0])
if (typeof chars.BS === 'number') setBS(chars.BS)
if (typeof chars.WS === 'number') setWS(chars.WS)
@@ -1188,25 +1121,6 @@ function DeathwatchRoller({ authedPlayer }) {
const allowedModes = useMemo(()=>{ const w = weapons.find(x=>x.name===weaponName); return w && Array.isArray(w.modes) && w.modes.length ? w.modes : ['single','semi','full'] },[weapons, weaponName])
const skillOptions = useMemo(() => {
return Object.keys(playerSkills || {}).sort((a, b) => {
const at = playerSkills[a]?.trained ? 0 : 1
const bt = playerSkills[b]?.trained ? 0 : 1
return at - bt || a.localeCompare(b)
})
}, [playerSkills])
const selectedSkill = playerSkills?.[skillRollName] || null
const selectedSkillCharacteristic = characteristicFromSkillName(skillRollName)
const selectedSkillBase = characteristicValue(playerCharacteristics, selectedSkillCharacteristic, 45)
const selectedSkillTrainingBonus = skillTrainingBonus(selectedSkill)
const selectedSkillTarget = Math.max(1, Math.min(100, selectedSkillBase + selectedSkillTrainingBonus + (Number(skillRollModifier) || 0)))
const skillTargetOverrideNum = parseInt(String(skillTargetOverride).trim(), 10)
const hasSkillTargetOverride = String(skillTargetOverride).trim() !== '' && Number.isFinite(skillTargetOverrideNum)
const effectiveSkillTarget = hasSkillTargetOverride
? Math.max(1, Math.min(100, skillTargetOverrideNum))
: selectedSkillTarget
const woundPct = maxWounds>0 ? Math.max(0, Math.min(100, (curWounds/maxWounds)*100)) : 0
const status = curWounds<=0 ? 'Dead' : (curWounds<maxWounds ? 'Wounded' : 'Alive')
const statusColor = status==='Dead' ? 'bg-rose-600' : status==='Wounded' ? 'bg-amber-500' : 'bg-emerald-600'
@@ -1229,47 +1143,6 @@ function DeathwatchRoller({ authedPlayer }) {
}
}
function rollSkillCheck() {
setError('')
setInfo('')
if (!skillRollName && !hasSkillTargetOverride) {
setError('Select a skill or enter a target number')
return
}
let roll
if (manualSkillRoll.trim()) {
const parsed = parseInt(manualSkillRoll.trim(), 10)
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 100) {
setError('Manual skill roll must be a number between 1-100')
return
}
roll = parsed
} else {
roll = d100()
}
const label = skillRollName || `Target ${effectiveSkillTarget}`
const dg = degrees(effectiveSkillTarget, roll)
const res = {
id: uid(),
ts: Date.now(),
rollKind: 'skill',
skillName: label,
characteristic: skillRollName ? selectedSkillCharacteristic : null,
base: skillRollName ? selectedSkillBase : null,
training: skillRollName ? skillTrainingLabel(selectedSkill) : null,
modifier: Number(skillRollModifier) || 0,
target: effectiveSkillTarget,
targetOverride: hasSkillTargetOverride,
attackRoll: roll,
success: dg.success,
dos: dg.dos,
dof: dg.dof,
using: skillRollName ? selectedSkillCharacteristic : null,
}
addRollResult(res)
setInfo(`${label}: ${dg.success ? `Success (${dg.dos} DoS)` : `Fail (${dg.dof} DoF)`}`)
}
return (
<section className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 p-6 md:p-10">
<div className="mx-auto max-w-6xl space-y-6">
@@ -1335,7 +1208,20 @@ function DeathwatchRoller({ authedPlayer }) {
</div>
<div className="grid md:grid-cols-3 gap-6">
<div className="md:col-span-2 rounded-2xl bg-slate-800 border border-slate-700 p-5 space-y-4 shadow-xl">
{awaitingDefense && pendingHits && (
<div className="flex gap-2">
<button onClick={()=>setRollerTab('combat')} className={`rounded-xl px-4 py-2 ${rollerTab==='combat' ? 'bg-sky-700' : 'bg-slate-700 hover:bg-slate-600'}`}>Combat Roller</button>
<button onClick={()=>setRollerTab('skill')} className={`rounded-xl px-4 py-2 ${rollerTab==='skill' ? 'bg-sky-700' : 'bg-slate-700 hover:bg-slate-600'}`}>Skill Roller</button>
</div>
{rollerTab === 'skill' && (
<SkillRoller
playerSkills={playerSkills}
playerCharacteristics={playerCharacteristics}
onResult={addRollResult}
setError={setError}
setInfo={setInfo}
/>
)}
{rollerTab === 'combat' && awaitingDefense && pendingHits && (
<div className="mb-4 rounded-xl bg-amber-800 border border-amber-600 p-4">
<div className="text-lg font-bold mb-2">Incoming Attack!</div>
<div className="text-sm mb-4">
@@ -1472,47 +1358,7 @@ function DeathwatchRoller({ authedPlayer }) {
</div>
)}
<div className="rounded-xl bg-slate-900 border border-slate-600 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold">Skill Check</div>
<div className="text-xs opacity-70">Uses this player sheet's skills and characteristics.</div>
</div>
<button onClick={rollSkillCheck} disabled={skillOptions.length === 0 && !hasSkillTargetOverride} className="rounded-xl px-4 py-2 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40">Roll Skill</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end">
<div className="md:col-span-2">
<label className="text-xs uppercase opacity-70">Skill</label>
<select className="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2" value={skillRollName} onChange={e=>setSkillRollName(e.target.value)} disabled={skillOptions.length === 0}>
{skillOptions.length === 0 && <option value="">No sheet skills loaded</option>}
{skillOptions.map(skill => (
<option key={skill} value={skill}>{skill} • {skillTrainingLabel(playerSkills[skill])}</option>
))}
</select>
</div>
<div>
<label className="text-xs uppercase opacity-70">Modifier</label>
<input className="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2" type="number" value={skillRollModifier} onChange={e=>setSkillRollModifier(parseInt(e.target.value||'0', 10))} disabled={hasSkillTargetOverride} />
</div>
<div>
<label className="text-xs uppercase opacity-70">Target</label>
<input className="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2" type="number" min="1" max="100" placeholder="auto" value={skillTargetOverride} onChange={e=>setSkillTargetOverride(e.target.value)} />
</div>
<div>
<label className="text-xs uppercase opacity-70">Manual d100</label>
<input className="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2" type="number" min="1" max="100" placeholder="optional" value={manualSkillRoll} onChange={e=>setManualSkillRoll(e.target.value)} />
</div>
</div>
<div className="text-sm opacity-85">
Target <span className="font-semibold">{effectiveSkillTarget}</span>
{hasSkillTargetOverride ? (
<span> = manual target</span>
) : skillRollName && (
<span> = {selectedSkillCharacteristic} {selectedSkillBase} {selectedSkillTrainingBonus >= 0 ? '+' : ''}{selectedSkillTrainingBonus} training {Number(skillRollModifier) ? `${Number(skillRollModifier) > 0 ? '+' : ''}${Number(skillRollModifier)} mod` : '+0 mod'}</span>
)}
</div>
</div>
{rollerTab === 'combat' && (<>
<div className="grid grid-cols-2 lg:grid-cols-6 gap-4 items-end">
<div className="lg:col-span-3">
<label className="text-xs uppercase opacity-70">Weapon</label>
@@ -1817,6 +1663,7 @@ function DeathwatchRoller({ authedPlayer }) {
))}
</div>
</div>
</>)}
</div>
<div className="space-y-6">
<div className="rounded-2xl bg-slate-800 backdrop-blur border border-slate-600 p-5 shadow-xl">

View File

@@ -0,0 +1,167 @@
import React, { useEffect, useMemo, useState } from 'react'
import { d100, degrees, uid } from './rollHelpers'
const CHARACTERISTIC_ALIASES = {
WS: ['WS', 'Ws', 'ws'],
BS: ['BS', 'Bs', 'bs'],
S: ['S', 's'],
T: ['T', 't'],
Ag: ['Ag', 'AG', 'ag'],
Int: ['Int', 'INT', 'int'],
Per: ['Per', 'PER', 'per'],
WP: ['WP', 'Wp', 'wp'],
Wp: ['WP', 'Wp', 'wp'],
Fel: ['Fel', 'FEL', 'fel'],
}
function characteristicFromSkillName(name) {
const match = String(name || '').match(/\(([^)]+)\)/)
const raw = match ? match[1].trim() : 'Int'
if (/^wp$/i.test(raw)) return 'WP'
if (/^ag$/i.test(raw)) return 'Ag'
if (/^int$/i.test(raw)) return 'Int'
if (/^per$/i.test(raw)) return 'Per'
if (/^fel$/i.test(raw)) return 'Fel'
if (/^bs$/i.test(raw)) return 'BS'
if (/^ws$/i.test(raw)) return 'WS'
if (/^s$/i.test(raw)) return 'S'
if (/^t$/i.test(raw)) return 'T'
return raw
}
function characteristicValue(chars, key, fallback = 45) {
const aliases = CHARACTERISTIC_ALIASES[key] || [key]
for (const alias of aliases) {
if (typeof chars?.[alias] === 'number') return chars[alias]
}
return fallback
}
function skillTrainingBonus(skill) {
if (skill?.plus20) return 20
if (skill?.plus10) return 10
if (skill?.trained) return 0
return -20
}
function skillTrainingLabel(skill) {
if (skill?.plus20) return 'Trained +20'
if (skill?.plus10) return 'Trained +10'
if (skill?.trained) return 'Trained'
return 'Untrained -20'
}
export default function SkillRoller({ playerSkills, playerCharacteristics, onResult, setError, setInfo }) {
const [skillRollName, setSkillRollName] = useState('')
const [skillRollModifier, setSkillRollModifier] = useState(0)
const [manualSkillRoll, setManualSkillRoll] = useState('')
const [skillTargetOverride, setSkillTargetOverride] = useState('')
const skillOptions = useMemo(() => {
return Object.keys(playerSkills || {}).sort((a, b) => {
const at = playerSkills[a]?.trained ? 0 : 1
const bt = playerSkills[b]?.trained ? 0 : 1
return at - bt || a.localeCompare(b)
})
}, [playerSkills])
// Preselect the first (best-trained) skill once the sheet loads.
useEffect(() => {
if (!skillRollName && skillOptions.length > 0) setSkillRollName(skillOptions[0])
}, [skillOptions, skillRollName])
const selectedSkill = playerSkills?.[skillRollName] || null
const selectedSkillCharacteristic = characteristicFromSkillName(skillRollName)
const selectedSkillBase = characteristicValue(playerCharacteristics, selectedSkillCharacteristic, 45)
const selectedSkillTrainingBonus = skillTrainingBonus(selectedSkill)
const selectedSkillTarget = Math.max(1, Math.min(100, selectedSkillBase + selectedSkillTrainingBonus + (Number(skillRollModifier) || 0)))
const skillTargetOverrideNum = parseInt(String(skillTargetOverride).trim(), 10)
const hasSkillTargetOverride = String(skillTargetOverride).trim() !== '' && Number.isFinite(skillTargetOverrideNum)
const effectiveSkillTarget = hasSkillTargetOverride
? Math.max(1, Math.min(100, skillTargetOverrideNum))
: selectedSkillTarget
function rollSkillCheck() {
setError('')
setInfo('')
if (!skillRollName && !hasSkillTargetOverride) {
setError('Select a skill or enter a target number')
return
}
let roll
if (manualSkillRoll.trim()) {
const parsed = parseInt(manualSkillRoll.trim(), 10)
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 100) {
setError('Manual skill roll must be a number between 1-100')
return
}
roll = parsed
} else {
roll = d100()
}
const label = skillRollName || `Target ${effectiveSkillTarget}`
const dg = degrees(effectiveSkillTarget, roll)
const res = {
id: uid(),
ts: Date.now(),
rollKind: 'skill',
skillName: label,
characteristic: skillRollName ? selectedSkillCharacteristic : null,
base: skillRollName ? selectedSkillBase : null,
training: skillRollName ? skillTrainingLabel(selectedSkill) : null,
modifier: Number(skillRollModifier) || 0,
target: effectiveSkillTarget,
targetOverride: hasSkillTargetOverride,
attackRoll: roll,
success: dg.success,
dos: dg.dos,
dof: dg.dof,
using: skillRollName ? selectedSkillCharacteristic : null,
}
onResult(res)
setInfo(`${label}: ${dg.success ? `Success (${dg.dos} DoS)` : `Fail (${dg.dof} DoF)`}`)
}
return (
<div className="rounded-xl bg-slate-900 border border-slate-600 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold">Skill Check</div>
<div className="text-xs opacity-70">Uses this player sheet's skills and characteristics.</div>
</div>
<button onClick={rollSkillCheck} disabled={skillOptions.length === 0 && !hasSkillTargetOverride} className="rounded-xl px-4 py-2 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40">Roll Skill</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end">
<div className="md:col-span-2">
<label className="text-xs uppercase opacity-70">Skill</label>
<select className="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2" value={skillRollName} onChange={e=>setSkillRollName(e.target.value)} disabled={skillOptions.length === 0}>
{skillOptions.length === 0 && <option value="">No sheet skills loaded</option>}
{skillOptions.map(skill => (
<option key={skill} value={skill}>{skill} • {skillTrainingLabel(playerSkills[skill])}</option>
))}
</select>
</div>
<div>
<label className="text-xs uppercase opacity-70">Modifier</label>
<input className="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2" type="number" value={skillRollModifier} onChange={e=>setSkillRollModifier(parseInt(e.target.value||'0', 10))} disabled={hasSkillTargetOverride} />
</div>
<div>
<label className="text-xs uppercase opacity-70">Target</label>
<input className="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2" type="number" min="1" max="100" placeholder="auto" value={skillTargetOverride} onChange={e=>setSkillTargetOverride(e.target.value)} />
</div>
<div>
<label className="text-xs uppercase opacity-70">Manual d100</label>
<input className="w-full rounded-xl border border-slate-600 bg-slate-800 px-3 py-2" type="number" min="1" max="100" placeholder="optional" value={manualSkillRoll} onChange={e=>setManualSkillRoll(e.target.value)} />
</div>
</div>
<div className="text-sm opacity-85">
Target <span className="font-semibold">{effectiveSkillTarget}</span>
{hasSkillTargetOverride ? (
<span> = manual target</span>
) : skillRollName && (
<span> = {selectedSkillCharacteristic} {selectedSkillBase} {selectedSkillTrainingBonus >= 0 ? '+' : ''}{selectedSkillTrainingBonus} training {Number(skillRollModifier) ? `${Number(skillRollModifier) > 0 ? '+' : ''}${Number(skillRollModifier)} mod` : '+0 mod'}</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
// Shared dice/degree helpers used by the combat and skill rollers.
export function d100() { return Math.floor(Math.random() * 100) + 1 }
export function degrees(target, roll) {
const success = roll <= target
if (success) { const diff = target - roll; const dos = 1 + Math.floor(diff / 10); return { success, dos, dof: 0 } }
const diff = roll - target; const dof = 1 + Math.floor(diff / 10); return { success, dos: 0, dof }
}
export function uid() { return Math.random().toString(36).slice(2) + Date.now().toString(36) }