diff --git a/database/backend.log b/database/backend.log index 9055994..7708317 100644 --- a/database/backend.log +++ b/database/backend.log @@ -13266,3 +13266,93 @@ Connected to MongoDB [2026-06-25T16:46:39.841Z] SESSION: Session validation successful phillip [2026-06-25T16:46:39.879Z] API: Fetch player phillip success [2026-06-25T16:47:37.213Z] MariaDB: Tables created successfully +[2026-06-30T08:30:21.371Z] API: Fetch all players (public) 7 +[2026-06-30T08:30:21.379Z] SESSION: Session validation successful anders +[2026-06-30T08:30:21.431Z] API: Fetch player andreas success +[2026-06-30T08:30:21.435Z] API: Fetch player anders success +[2026-06-30T08:30:21.440Z] API: Fetch player christoffer success +[2026-06-30T08:30:21.443Z] API: Fetch player chris success +[2026-06-30T08:30:21.448Z] API: Fetch player claes success +[2026-06-30T08:30:21.451Z] API: Fetch player phillip success +[2026-06-30T08:30:21.488Z] API: Fetch player anders success +[2026-06-30T08:30:25.357Z] MariaDB: Error getting all weapons {"message":"Table 'deathwatch.weapons' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM weapons ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.weapons' doesn't exist"} +[2026-06-30T08:30:25.368Z] MariaDB: Error getting all bestiary {"message":"Table 'deathwatch.bestiary' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM bestiary ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.bestiary' doesn't exist"} +[2026-06-30T08:30:25.383Z] API: Fetch player anders success +[2026-06-30T10:04:17.831Z] SESSION: Session validation successful anders +[2026-06-30T10:04:17.842Z] API: Fetch all players (public) 7 +[2026-06-30T10:04:17.889Z] API: Fetch player anders success +[2026-06-30T10:04:17.918Z] API: Fetch player chris success +[2026-06-30T10:04:17.923Z] API: Fetch player andreas success +[2026-06-30T10:04:17.926Z] API: Fetch player claes success +[2026-06-30T10:04:17.929Z] API: Fetch player christoffer success +[2026-06-30T10:04:17.939Z] API: Fetch player phillip success +[2026-06-30T10:04:17.942Z] API: Fetch player anders success +[2026-06-30T10:04:19.561Z] MariaDB: Error getting all bestiary {"message":"Table 'deathwatch.bestiary' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM bestiary ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.bestiary' doesn't exist"} +[2026-06-30T10:04:19.580Z] API: Fetch player anders success +[2026-06-30T10:04:19.584Z] MariaDB: Error getting all weapons {"message":"Table 'deathwatch.weapons' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM weapons ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.weapons' doesn't exist"} +[2026-06-30T10:18:35.910Z] API: Fetch all players (public) 7 +[2026-06-30T10:18:35.913Z] SESSION: Session validation successful anders +[2026-06-30T10:18:36.013Z] API: Fetch player anders success +[2026-06-30T10:18:36.018Z] API: Fetch player andreas success +[2026-06-30T10:18:36.025Z] API: Fetch player chris success +[2026-06-30T10:18:36.033Z] API: Fetch player claes success +[2026-06-30T10:18:36.036Z] API: Fetch player christoffer success +[2026-06-30T10:18:36.042Z] API: Fetch player phillip success +[2026-06-30T10:18:36.070Z] API: Fetch player anders success +[2026-06-30T10:18:36.750Z] API: Fetch all players (public) 7 +[2026-06-30T10:18:36.753Z] SESSION: Session validation successful anders +[2026-06-30T10:18:36.812Z] API: Fetch player christoffer success +[2026-06-30T10:18:36.815Z] API: Fetch player andreas success +[2026-06-30T10:18:36.817Z] API: Fetch player chris success +[2026-06-30T10:18:36.821Z] API: Fetch player anders success +[2026-06-30T10:18:36.826Z] API: Fetch player claes success +[2026-06-30T10:18:36.829Z] API: Fetch player phillip success +[2026-06-30T10:18:36.898Z] API: Fetch player anders success +[2026-06-30T10:18:39.432Z] SESSION: Session validation successful anders +[2026-06-30T10:18:39.442Z] API: Fetch all players (public) 7 +[2026-06-30T10:18:39.503Z] API: Fetch player anders success +[2026-06-30T10:18:39.518Z] API: Fetch player chris success +[2026-06-30T10:18:39.522Z] API: Fetch player andreas success +[2026-06-30T10:18:39.525Z] API: Fetch player christoffer success +[2026-06-30T10:18:39.527Z] API: Fetch player claes success +[2026-06-30T10:18:39.530Z] API: Fetch player phillip success +[2026-06-30T10:18:39.558Z] API: Fetch player anders success +[2026-06-30T10:18:45.220Z] MariaDB: Error getting all weapons {"message":"Table 'deathwatch.weapons' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM weapons ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.weapons' doesn't exist"} +[2026-06-30T10:18:45.228Z] MariaDB: Error getting all bestiary {"message":"Table 'deathwatch.bestiary' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM bestiary ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.bestiary' doesn't exist"} +[2026-06-30T10:18:45.242Z] API: Fetch player anders success +[2026-06-30T10:24:37.183Z] API: Fetch all players (public) 7 +[2026-06-30T10:24:37.205Z] SESSION: Session validation successful anders +[2026-06-30T10:24:37.262Z] API: Fetch player anders success +[2026-06-30T10:24:37.266Z] API: Fetch player christoffer success +[2026-06-30T10:24:37.269Z] API: Fetch player chris success +[2026-06-30T10:24:37.271Z] API: Fetch player andreas success +[2026-06-30T10:24:37.274Z] API: Fetch player phillip success +[2026-06-30T10:24:37.279Z] API: Fetch player claes success +[2026-06-30T10:24:37.314Z] API: Fetch player anders success +[2026-06-30T10:24:38.549Z] MariaDB: Error getting all weapons {"message":"Table 'deathwatch.weapons' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM weapons ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.weapons' doesn't exist"} +[2026-06-30T10:24:38.557Z] MariaDB: Error getting all bestiary {"message":"Table 'deathwatch.bestiary' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM bestiary ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.bestiary' doesn't exist"} +[2026-06-30T10:24:38.583Z] API: Fetch player anders success +[2026-06-30T10:32:47.797Z] API: Fetch all players (public) 7 +[2026-06-30T10:32:47.807Z] SESSION: Session validation successful anders +[2026-06-30T10:32:47.880Z] API: Fetch player anders success +[2026-06-30T10:32:47.885Z] API: Fetch player chris success +[2026-06-30T10:32:47.888Z] API: Fetch player phillip success +[2026-06-30T10:32:47.891Z] API: Fetch player christoffer success +[2026-06-30T10:32:47.913Z] API: Fetch player andreas success +[2026-06-30T10:32:47.918Z] API: Fetch player claes success +[2026-06-30T10:32:47.932Z] API: Fetch player anders success +[2026-06-30T10:32:49.096Z] MariaDB: Error getting all bestiary {"message":"Table 'deathwatch.bestiary' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM bestiary ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.bestiary' doesn't exist"} +[2026-06-30T10:32:49.111Z] API: Fetch player anders success +[2026-06-30T10:32:49.113Z] MariaDB: Error getting all weapons {"message":"Table 'deathwatch.weapons' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM weapons ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.weapons' doesn't exist"} +[2026-06-30T10:36:49.883Z] API: Fetch all players (public) 7 +[2026-06-30T10:36:49.914Z] SESSION: Session validation successful anders +[2026-06-30T10:36:49.949Z] API: Fetch player anders success +[2026-06-30T10:36:49.952Z] API: Fetch player chris success +[2026-06-30T10:36:49.956Z] API: Fetch player andreas success +[2026-06-30T10:36:49.959Z] API: Fetch player phillip success +[2026-06-30T10:36:49.966Z] API: Fetch player christoffer success +[2026-06-30T10:36:49.969Z] API: Fetch player claes success +[2026-06-30T10:36:50.002Z] API: Fetch player anders success +[2026-06-30T10:36:51.470Z] API: Fetch player anders success +[2026-06-30T10:36:51.473Z] MariaDB: Error getting all weapons {"message":"Table 'deathwatch.weapons' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM weapons ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.weapons' doesn't exist"} +[2026-06-30T10:36:51.479Z] MariaDB: Error getting all bestiary {"message":"Table 'deathwatch.bestiary' doesn't exist","code":"ER_NO_SUCH_TABLE","errno":1146,"sql":"SELECT * FROM bestiary ORDER BY id","sqlState":"42S02","sqlMessage":"Table 'deathwatch.bestiary' doesn't exist"} diff --git a/database/routes/playerRoutes.js b/database/routes/playerRoutes.js index 608d8eb..ed2d369 100644 --- a/database/routes/playerRoutes.js +++ b/database/routes/playerRoutes.js @@ -141,6 +141,41 @@ router.put('/:name', async (req, res) => { } }); +// Spend a single fate point (atomic read-modify-write, preserves other fields) +router.post('/:name/spend-fate', async (req, res) => { + try { + const { name } = req.params; + const player = await playerHelpers.getByName(name); + if (!player) return res.status(404).json({ error: 'Player not found' }); + + const tabInfo = player.tabInfo || {}; + let fate = tabInfo.fate; + if (typeof fate !== 'object' || fate === null) { + const n = Number(fate) || 0; + fate = { current: n, total: n }; + } + const current = Number(fate.current) || 0; + if (current <= 0) return res.status(400).json({ error: 'No fate points remaining' }); + + const newFate = { ...fate, current: current - 1 }; + const ok = await playerHelpers.update(name, { + rollerInfo: player.rollerInfo, + shopInfo: player.shopInfo, + tabInfo: { ...tabInfo, fate: newFate }, + pw: player.pw, + pwHash: player.pwHash, + }); + if (!ok) return res.status(500).json({ error: 'Failed to update fate' }); + + logToFile('API: Spent fate point', name, `${current} -> ${newFate.current}`); + res.json({ success: true, fate: newFate }); + } catch (error) { + console.error('Spend fate error:', error); + logToFile('API: Failed to spend fate', req.params.name, error); + res.status(500).json({ error: String(error) }); + } +}); + // Create new player router.post('/', async (req, res) => { try { diff --git a/src/components/DeathwatchRoller.jsx b/src/components/DeathwatchRoller.jsx index 0626fb1..02f0658 100755 --- a/src/components/DeathwatchRoller.jsx +++ b/src/components/DeathwatchRoller.jsx @@ -1,7 +1,9 @@ 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'; +import FateControls from './FateControls'; +import { useFate } from './useFate'; +import { applyFateToTest, d100, degrees, uid } from './rollHelpers'; // Tooltip component for abbreviations function Tooltip({ children, text }) { @@ -422,6 +424,8 @@ function DeathwatchRoller({ authedPlayer }) { const [playerSkills, setPlayerSkills] = useState({}) const [playerCharacteristics, setPlayerCharacteristics] = useState({}) const [rollerTab, setRollerTab] = useState('combat') + const [lastCombatResult, setLastCombatResult] = useState(null) + const { fate, spendFate } = useFate(authedPlayer) // Load enemies from database API async function loadEnemiesFromAPI() { @@ -1110,7 +1114,7 @@ function DeathwatchRoller({ authedPlayer }) { const status = curWounds<=0 ? 'Dead' : (curWounds [result, ...prev].slice(0, 50)) try { fetch('/api/missions/rolls', { @@ -1128,6 +1132,28 @@ function DeathwatchRoller({ authedPlayer }) { } } + // Combat results also become the "last roll" that fate points can modify. + function addRollResult(result) { + setLastCombatResult(result) + recordResult(result) + } + + // Replace the most recent history entry in place (used by fate re-rolls). + function replaceLastResult(result) { + setHistory(prev => prev.length ? [result, ...prev.slice(1)] : [result]) + } + + async function applyCombatFate(action) { + if (!lastCombatResult) return + const ok = await spendFate() + if (!ok) { setError('No fate points remaining'); return } + const next = applyFateToTest(lastCombatResult, action) + if (typeof next.mode === 'string') next.hits = hitsFromDoS(next.mode, next.dos, next.rof) + setLastCombatResult(next) + replaceLastResult(next) + setInfo(`Attack ${next.success ? `hit (${next.dos} DoS, ${next.hits} hit${next.hits === 1 ? '' : 's'})` : `missed (${next.dof} DoF)`} ✧ ${next.fate.join(', ')} — re-resolve damage if needed`) + } + return (
@@ -1201,7 +1227,10 @@ function DeathwatchRoller({ authedPlayer }) { @@ -1542,9 +1571,16 @@ function DeathwatchRoller({ authedPlayer }) { )}
-
+
Target {target}
+ applyCombatFate('reroll')} + onPlus10={()=>applyCombatFate('plus10')} + onAddDos={()=>applyCombatFate('dos')} + />
{/* Progress tracker */}
diff --git a/src/components/FateControls.jsx b/src/components/FateControls.jsx new file mode 100644 index 0000000..043ba62 --- /dev/null +++ b/src/components/FateControls.jsx @@ -0,0 +1,18 @@ +import React from 'react' + +// Shared fate-point control: shows current/total and three spend actions that +// modify the roller's most recent roll. `disabled` is true when there is no +// roll to modify; the buttons additionally require a remaining fate point. +export default function FateControls({ fate, disabled, onReroll, onPlus10, onAddDos }) { + const current = fate ? fate.current : 0 + const noFate = !fate || current <= 0 + const btn = 'rounded-xl px-3 py-1.5 text-sm bg-purple-800 hover:bg-purple-700 disabled:opacity-40' + return ( +
+ ✧ Fate {fate ? `${current}/${fate.total}` : '—'} + + + +
+ ) +} diff --git a/src/components/SkillRoller.jsx b/src/components/SkillRoller.jsx index 940184f..9245c98 100644 --- a/src/components/SkillRoller.jsx +++ b/src/components/SkillRoller.jsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react' -import { d100, degrees, uid } from './rollHelpers' +import { applyFateToTest, d100, degrees, uid } from './rollHelpers' +import FateControls from './FateControls' const CHARACTERISTIC_ALIASES = { WS: ['WS', 'Ws', 'ws'], @@ -51,11 +52,12 @@ function skillTrainingLabel(skill) { return 'Untrained -20' } -export default function SkillRoller({ playerSkills, playerCharacteristics, onResult, setError, setInfo }) { +export default function SkillRoller({ playerSkills, playerCharacteristics, onResult, onReplaceResult, fate, spendFate, setError, setInfo }) { const [skillRollName, setSkillRollName] = useState('') const [skillRollModifier, setSkillRollModifier] = useState(0) const [manualSkillRoll, setManualSkillRoll] = useState('') const [skillTargetOverride, setSkillTargetOverride] = useState('') + const [lastResult, setLastResult] = useState(null) const skillOptions = useMemo(() => { return Object.keys(playerSkills || {}).sort((a, b) => { @@ -118,10 +120,21 @@ export default function SkillRoller({ playerSkills, playerCharacteristics, onRes dof: dg.dof, using: skillRollName ? selectedSkillCharacteristic : null, } + setLastResult(res) onResult(res) setInfo(`${label}: ${dg.success ? `Success (${dg.dos} DoS)` : `Fail (${dg.dof} DoF)`}`) } + async function applyFate(action) { + if (!lastResult) return + const ok = await spendFate() + if (!ok) { setError('No fate points remaining'); return } + const next = applyFateToTest(lastResult, action) + setLastResult(next) + onReplaceResult(next) + setInfo(`${next.skillName}: ${next.success ? `Success (${next.dos} DoS)` : `Fail (${next.dof} DoF)`} ✧ ${next.fate.join(', ')}`) + } + return (
@@ -162,6 +175,13 @@ export default function SkillRoller({ playerSkills, playerCharacteristics, onRes = {selectedSkillCharacteristic} {selectedSkillBase} {selectedSkillTrainingBonus >= 0 ? '+' : ''}{selectedSkillTrainingBonus} training {Number(skillRollModifier) ? `${Number(skillRollModifier) > 0 ? '+' : ''}${Number(skillRollModifier)} mod` : '+0 mod'} )}
+ applyFate('reroll')} + onPlus10={()=>applyFate('plus10')} + onAddDos={()=>applyFate('dos')} + />
) } diff --git a/src/components/rollHelpers.js b/src/components/rollHelpers.js index 816e5fe..fa56f05 100644 --- a/src/components/rollHelpers.js +++ b/src/components/rollHelpers.js @@ -9,3 +9,29 @@ export function degrees(target, roll) { } export function uid() { return Math.random().toString(36).slice(2) + Date.now().toString(36) } + +// Apply a spent fate point to a recorded test result (one with target / +// attackRoll / success / dos / dof). Returns a new result object; the caller +// is responsible for decrementing/persisting the fate point itself. +// 'reroll' - re-roll the d100 against the same target +// 'plus10' - add +10 to the target and re-evaluate the same roll +// 'dos' - add one degree of success (turning a failure into a pass) +export function applyFateToTest(prev, action) { + const out = { ...prev, fate: [...(prev.fate || [])] } + if (action === 'reroll') { + out.attackRoll = d100() + const dg = degrees(out.target, out.attackRoll) + out.success = dg.success; out.dos = dg.dos; out.dof = dg.dof + out.fate.push('reroll') + } else if (action === 'plus10') { + out.target = Math.min(100, (Number(out.target) || 0) + 10) + const dg = degrees(out.target, out.attackRoll) + out.success = dg.success; out.dos = dg.dos; out.dof = dg.dof + out.fate.push('+10') + } else if (action === 'dos') { + if (out.success) { out.dos = (Number(out.dos) || 0) + 1 } + else { out.success = true; out.dos = 1; out.dof = 0 } + out.fate.push('+DoS') + } + return out +} diff --git a/src/components/useFate.js b/src/components/useFate.js new file mode 100644 index 0000000..7b377a1 --- /dev/null +++ b/src/components/useFate.js @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useState } from 'react' + +// Loads the logged-in player's fate points and spends them (persisted to the +// sheet via the backend). `fate` is { current, total } or null while unknown. +export function useFate(authedPlayer) { + const [fate, setFate] = useState(null) + + const loadFate = useCallback(async () => { + if (!authedPlayer) { setFate(null); return } + try { + const res = await fetch(`/api/players/${authedPlayer}`, { cache: 'no-store' }) + if (!res.ok) return + const player = await res.json() + const raw = player?.tabInfo?.fate + if (raw && typeof raw === 'object') setFate({ current: Number(raw.current) || 0, total: Number(raw.total) || 0 }) + else if (raw != null) setFate({ current: Number(raw) || 0, total: Number(raw) || 0 }) + else setFate({ current: 0, total: 0 }) + } catch { + // Leave fate as-is; the roller still works without it. + } + }, [authedPlayer]) + + useEffect(() => { loadFate() }, [loadFate]) + + // Returns true if a point was spent. Updates local state from the server. + const spendFate = useCallback(async () => { + if (!authedPlayer) return false + if (fate && fate.current <= 0) return false + try { + const res = await fetch(`/api/players/${authedPlayer}/spend-fate`, { method: 'POST' }) + if (!res.ok) return false + const data = await res.json() + if (data?.fate) setFate({ current: Number(data.fate.current) || 0, total: Number(data.fate.total) || 0 }) + return true + } catch { + return false + } + }, [authedPlayer, fate]) + + return { fate, spendFate, refreshFate: loadFate } +}