From 36378ff7022017aff44b5e2aa7a5ab00e0e5947c Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 30 Jun 2026 12:30:03 +0200 Subject: [PATCH] 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 --- src/components/DeathwatchRoller.jsx | 191 +++------------------------- src/components/SkillRoller.jsx | 167 ++++++++++++++++++++++++ src/components/rollHelpers.js | 11 ++ 3 files changed, 197 insertions(+), 172 deletions(-) create mode 100644 src/components/SkillRoller.jsx create mode 100644 src/components/rollHelpers.js diff --git a/src/components/DeathwatchRoller.jsx b/src/components/DeathwatchRoller.jsx index 1e58a85..26f1a03 100755 --- a/src/components/DeathwatchRoller.jsx +++ b/src/components/DeathwatchRoller.jsx @@ -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 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 (
@@ -1335,7 +1208,20 @@ function DeathwatchRoller({ authedPlayer }) {
- {awaitingDefense && pendingHits && ( +
+ + +
+ {rollerTab === 'skill' && ( + + )} + {rollerTab === 'combat' && awaitingDefense && pendingHits && (
Incoming Attack!
@@ -1472,47 +1358,7 @@ function DeathwatchRoller({ authedPlayer }) {
)} -
-
-
-
Skill Check
-
Uses this player sheet's skills and characteristics.
-
- -
-
-
- - -
-
- - setSkillRollModifier(parseInt(e.target.value||'0', 10))} disabled={hasSkillTargetOverride} /> -
-
- - setSkillTargetOverride(e.target.value)} /> -
-
- - setManualSkillRoll(e.target.value)} /> -
-
-
- Target {effectiveSkillTarget} - {hasSkillTargetOverride ? ( - = manual target - ) : skillRollName && ( - = {selectedSkillCharacteristic} {selectedSkillBase} {selectedSkillTrainingBonus >= 0 ? '+' : ''}{selectedSkillTrainingBonus} training {Number(skillRollModifier) ? `${Number(skillRollModifier) > 0 ? '+' : ''}${Number(skillRollModifier)} mod` : '+0 mod'} - )} -
-
- + {rollerTab === 'combat' && (<>
@@ -1817,6 +1663,7 @@ function DeathwatchRoller({ authedPlayer }) { ))}
+ )}
diff --git a/src/components/SkillRoller.jsx b/src/components/SkillRoller.jsx new file mode 100644 index 0000000..940184f --- /dev/null +++ b/src/components/SkillRoller.jsx @@ -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 ( +
+
+
+
Skill Check
+
Uses this player sheet's skills and characteristics.
+
+ +
+
+
+ + +
+
+ + setSkillRollModifier(parseInt(e.target.value||'0', 10))} disabled={hasSkillTargetOverride} /> +
+
+ + setSkillTargetOverride(e.target.value)} /> +
+
+ + setManualSkillRoll(e.target.value)} /> +
+
+
+ Target {effectiveSkillTarget} + {hasSkillTargetOverride ? ( + = manual target + ) : skillRollName && ( + = {selectedSkillCharacteristic} {selectedSkillBase} {selectedSkillTrainingBonus >= 0 ? '+' : ''}{selectedSkillTrainingBonus} training {Number(skillRollModifier) ? `${Number(skillRollModifier) > 0 ? '+' : ''}${Number(skillRollModifier)} mod` : '+0 mod'} + )} +
+
+ ) +} diff --git a/src/components/rollHelpers.js b/src/components/rollHelpers.js new file mode 100644 index 0000000..816e5fe --- /dev/null +++ b/src/components/rollHelpers.js @@ -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) }