Persist mission scene session state

This commit is contained in:
2026-07-02 11:55:12 +02:00
parent 0fa8c39c4d
commit 8225997107
2 changed files with 96 additions and 17 deletions

View File

@@ -802,7 +802,7 @@ const missionRollHelpers = {
getForMission: async (missionId, limit = 50) => {
try {
const boundedLimit = Math.max(1, Math.min(Number(limit) || 50, 100));
const boundedLimit = Math.max(1, Math.min(Number(limit) || 50, 500));
const [rows] = await pool.execute(
`SELECT * FROM mission_rolls WHERE mission_id = ? ORDER BY created_at DESC LIMIT ${boundedLimit}`,
[missionId]

View File

@@ -413,7 +413,7 @@ export default function MissionTab({ authedPlayer }) {
// #1 — roll feed visible to all (no isGM guard)
const loadRollFeed = useCallback(async function loadRollFeed() {
try {
const res = await axios.get('/api/missions/active/rolls/feed?limit=50');
const res = await axios.get('/api/missions/active/rolls/feed?limit=200');
const next = Array.isArray(res.data) ? res.data : [];
setRollFeed(prev => JSON.stringify(prev) !== JSON.stringify(next) ? next : prev);
} catch (e) {
@@ -470,11 +470,20 @@ export default function MissionTab({ authedPlayer }) {
const currentSceneIndex = activeMission?.current_scene ?? 0;
useEffect(() => {
setRolledChecks(new Set());
setFateUsedChecks(new Set());
setDeclarationText('');
setWpTarget('60');
}, [currentSceneIndex]);
const sessionState = currentScene?.sessionState || {};
const playerKey = authedPlayer || 'unknown';
const playerRolled = sessionState.rolledChecksByPlayer?.[playerKey];
const playerFate = sessionState.fateUsedChecksByPlayer?.[playerKey];
const playerResults = sessionState.rollResultsByPlayer?.[playerKey];
setRolledChecks(new Set(Array.isArray(playerRolled) ? playerRolled : (Array.isArray(sessionState.rolledChecks) ? sessionState.rolledChecks : [])));
setFateUsedChecks(new Set(Array.isArray(playerFate) ? playerFate : (Array.isArray(sessionState.fateUsedChecks) ? sessionState.fateUsedChecks : [])));
setRollResults(Array.isArray(playerResults) ? playerResults.slice(0, 5) : (Array.isArray(sessionState.rollResults) ? sessionState.rollResults.slice(0, 5) : []));
setDeclarationText(sessionState.declarationText || '');
setWpTarget(String(sessionState.wpTarget || '60'));
if (sessionState.miniRollerState && typeof sessionState.miniRollerState === 'object') {
setMiniRollerState(prev => ({ ...prev, [activeSceneIndex]: sessionState.miniRollerState }));
}
}, [activeMission?.id, currentSceneIndex, activeSceneIndex, currentScene, authedPlayer]);
// sync outcome notes when a different mission loads (keyed on id, not scenes array)
useEffect(() => {
@@ -724,6 +733,45 @@ export default function MissionTab({ authedPlayer }) {
await loadMissions({ silent: true });
}
async function patchActiveSceneSessionState(updates, sceneIndex = activeSceneIndex, scene = currentScene) {
if (!scene) return;
const sessionState = {
...(scene.sessionState || {}),
...updates,
updatedAt: new Date().toISOString(),
};
await patchSceneAt(sceneIndex, { sessionState });
}
function playerSessionStatePatch({
rolled = rolledChecks,
fate = fateUsedChecks,
results = rollResults,
declaration = declarationText,
wp = wpTarget,
extra = {},
} = {}) {
const sessionState = currentScene?.sessionState || {};
const playerKey = authedPlayer || 'unknown';
return {
...extra,
rolledChecksByPlayer: {
...(sessionState.rolledChecksByPlayer || {}),
[playerKey]: [...rolled],
},
fateUsedChecksByPlayer: {
...(sessionState.fateUsedChecksByPlayer || {}),
[playerKey]: [...fate],
},
rollResultsByPlayer: {
...(sessionState.rollResultsByPlayer || {}),
[playerKey]: results,
},
wpTarget: wp,
declarationText: declaration,
};
}
async function updateCombatState(updates) {
if (!activeMission?.scenes?.length) return;
const index = activeMission.current_scene;
@@ -885,8 +933,10 @@ export default function MissionTab({ authedPlayer }) {
rolledBy: authedPlayer,
...result,
};
setRolledChecks(prev => new Set([...prev, check.name]));
setRollResults(prev => [entry, ...prev].slice(0, 5));
const nextRolledChecks = new Set([...rolledChecks, check.name]);
const nextRollResults = [entry, ...rollResults].slice(0, 5);
setRolledChecks(nextRolledChecks);
setRollResults(nextRollResults);
await recordMissionRoll('check', check.name, {
sceneTitle: titleForScene(currentScene, activeSceneIndex),
check,
@@ -894,12 +944,16 @@ export default function MissionTab({ authedPlayer }) {
roll,
...result,
});
await patchActiveSceneSessionState(playerSessionStatePatch({
rolled: nextRolledChecks,
results: nextRollResults,
}));
}
function miniState(sceneIndex, scene) {
const checks = listForScene(scene, 'checks').length ? listForScene(scene, 'checks') : defaultChecksForScene(scene);
const enemies = sceneEnemyObjects(scene);
const current = miniRollerState[sceneIndex] || {};
const current = miniRollerState[sceneIndex] || scene?.sessionState?.miniRollerState || {};
const defaultPlayer = isGM ? (playerList[0] || '') : authedPlayer;
return {
player: current.player || defaultPlayer,
@@ -1020,7 +1074,14 @@ export default function MissionTab({ authedPlayer }) {
},
},
};
await patchSceneAt(sceneIndex, { rollLocks });
await patchSceneAt(sceneIndex, {
rollLocks,
sessionState: {
...(scene.sessionState || {}),
miniRollerState: state,
updatedAt: new Date().toISOString(),
},
});
await recordMissionRollForScene(feedSceneIndex, state.player, 'check', check.name, {
sceneTitle: titleForScene(scene, sceneIndex),
mini: true,
@@ -1134,24 +1195,42 @@ export default function MissionTab({ authedPlayer }) {
rolledBy: authedPlayer,
...result,
};
setRolledChecks(prev => new Set([...prev, '__fear__']));
setRollResults(prev => [entry, ...prev].slice(0, 5));
const nextRolledChecks = new Set([...rolledChecks, '__fear__']);
const nextRollResults = [entry, ...rollResults].slice(0, 5);
setRolledChecks(nextRolledChecks);
setRollResults(nextRollResults);
await recordMissionRoll('fear', 'Fear Test', { target, roll, ...result });
await patchActiveSceneSessionState(playerSessionStatePatch({
rolled: nextRolledChecks,
results: nextRollResults,
}));
}
// #8 — decision declare
async function rollDecision() {
const text = declarationText.trim();
if (!text) return;
setRolledChecks(prev => new Set([...prev, playPrompt?.check?.name || '__decision__']));
const nextRolledChecks = new Set([...rolledChecks, playPrompt?.check?.name || '__decision__']);
setRolledChecks(nextRolledChecks);
await recordMissionRoll('decision', playPrompt?.title || 'Decision', { declaration: text });
setDeclarationText('');
await patchActiveSceneSessionState(playerSessionStatePatch({
rolled: nextRolledChecks,
declaration: '',
}));
}
// #9 — fate point re-roll
function spendFate(checkName) {
setFateUsedChecks(prev => new Set([...prev, checkName]));
setRolledChecks(prev => { const next = new Set(prev); next.delete(checkName); return next; });
async function spendFate(checkName) {
const nextFateUsedChecks = new Set([...fateUsedChecks, checkName]);
const nextRolledChecks = new Set(rolledChecks);
nextRolledChecks.delete(checkName);
setFateUsedChecks(nextFateUsedChecks);
setRolledChecks(nextRolledChecks);
await patchActiveSceneSessionState(playerSessionStatePatch({
rolled: nextRolledChecks,
fate: nextFateUsedChecks,
}));
}
function openSceneInRoller() {