Add fate-point support to skill and combat rollers
Players can spend a fate point to re-roll, add +10, or add a degree of success to their most recent roll. Spends are persisted to the player's sheet via a new atomic POST /api/players/:name/spend-fate endpoint (read-modify-write that preserves password and other tabInfo). - rollHelpers.applyFateToTest: shared recompute for a test result - useFate hook: loads current/total fate, spends + persists - FateControls: shared current/total display + Re-roll/+10/+DoS buttons - SkillRoller and the combat roller each track their last roll and replace it in history when fate is spent; combat also recomputes hits. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<maxWounds ? 'Wounded' : 'Alive')
|
||||
const statusColor = status==='Dead' ? 'bg-rose-600' : status==='Wounded' ? 'bg-amber-500' : 'bg-emerald-600'
|
||||
|
||||
function addRollResult(result) {
|
||||
function recordResult(result) {
|
||||
setHistory(prev => [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 (
|
||||
<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">
|
||||
@@ -1201,7 +1227,10 @@ function DeathwatchRoller({ authedPlayer }) {
|
||||
<SkillRoller
|
||||
playerSkills={playerSkills}
|
||||
playerCharacteristics={playerCharacteristics}
|
||||
onResult={addRollResult}
|
||||
onResult={recordResult}
|
||||
onReplaceResult={replaceLastResult}
|
||||
fate={fate}
|
||||
spendFate={spendFate}
|
||||
setError={setError}
|
||||
setInfo={setInfo}
|
||||
/>
|
||||
@@ -1542,9 +1571,16 @@ function DeathwatchRoller({ authedPlayer }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button onClick={runAttack} className="rounded-xl px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:scale-[.99] transition">Roll</button>
|
||||
<div className="text-sm opacity-80">Target <span className="font-semibold">{target}</span></div>
|
||||
<FateControls
|
||||
fate={fate}
|
||||
disabled={!lastCombatResult}
|
||||
onReroll={()=>applyCombatFate('reroll')}
|
||||
onPlus10={()=>applyCombatFate('plus10')}
|
||||
onAddDos={()=>applyCombatFate('dos')}
|
||||
/>
|
||||
</div>
|
||||
{/* Progress tracker */}
|
||||
<div className="mt-3">
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-purple-300">✧ Fate {fate ? `${current}/${fate.total}` : '—'}</span>
|
||||
<button className={btn} disabled={disabled || noFate} onClick={onReroll} title="Spend a fate point to re-roll">Re-roll</button>
|
||||
<button className={btn} disabled={disabled || noFate} onClick={onPlus10} title="Spend a fate point for +10">+10</button>
|
||||
<button className={btn} disabled={disabled || noFate} onClick={onAddDos} title="Spend a fate point for +1 degree of success">+DoS</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<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">
|
||||
@@ -162,6 +175,13 @@ export default function SkillRoller({ playerSkills, playerCharacteristics, onRes
|
||||
<span> = {selectedSkillCharacteristic} {selectedSkillBase} {selectedSkillTrainingBonus >= 0 ? '+' : ''}{selectedSkillTrainingBonus} training {Number(skillRollModifier) ? `${Number(skillRollModifier) > 0 ? '+' : ''}${Number(skillRollModifier)} mod` : '+0 mod'}</span>
|
||||
)}
|
||||
</div>
|
||||
<FateControls
|
||||
fate={fate}
|
||||
disabled={!lastResult}
|
||||
onReroll={()=>applyFate('reroll')}
|
||||
onPlus10={()=>applyFate('plus10')}
|
||||
onAddDos={()=>applyFate('dos')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user