diff --git a/database/routes/missionRoutes.js b/database/routes/missionRoutes.js index 98c51a3..e1695ad 100644 --- a/database/routes/missionRoutes.js +++ b/database/routes/missionRoutes.js @@ -18,6 +18,11 @@ function playerScene(scene) { completionText, ...safeScene } = scene; + if (!safeScene.skillConditionsUnlocked) { + delete safeScene.skillDifficultyModifier; + delete safeScene.skillSituationModifiers; + delete safeScene.skillCustomModifier; + } return safeScene; } diff --git a/src/components/MissionTab.jsx b/src/components/MissionTab.jsx index 132e47e..1a73a57 100644 --- a/src/components/MissionTab.jsx +++ b/src/components/MissionTab.jsx @@ -881,9 +881,11 @@ export default function MissionTab({ authedPlayer }) { return { player: current.player || defaultPlayer, checkName: current.checkName || checks[0]?.name || '', - skillDifficultyModifier: current.skillDifficultyModifier ?? 0, - skillSituationModifiers: Array.isArray(current.skillSituationModifiers) ? current.skillSituationModifiers : [], - skillCustomModifier: current.skillCustomModifier ?? 0, + skillDifficultyModifier: current.skillDifficultyModifier ?? scene?.skillDifficultyModifier ?? 0, + skillSituationModifiers: Array.isArray(current.skillSituationModifiers) + ? current.skillSituationModifiers + : (Array.isArray(scene?.skillSituationModifiers) ? scene.skillSituationModifiers : []), + skillCustomModifier: current.skillCustomModifier ?? scene?.skillCustomModifier ?? 0, enemyName: current.enemyName || enemies[0]?.name || '', attackSkill: current.attackSkill || 'BS', modifier: current.modifier ?? 0, @@ -899,6 +901,27 @@ export default function MissionTab({ authedPlayer }) { })); } + async function setMiniSkillSettings(sceneIndex, updates) { + setMiniState(sceneIndex, updates); + if (!isGM) return; + await patchSceneAt(sceneIndex, updates); + } + + function playerCanSeeSkillConditions(scene) { + return isGM || Boolean(scene?.skillConditionsUnlocked); + } + + function skillModifierPayloadForScene(scene, state) { + if (playerCanSeeSkillConditions(scene)) { + return { + difficulty: state.skillDifficultyModifier, + situations: state.skillSituationModifiers, + custom: state.skillCustomModifier, + }; + } + return { difficulty: 0, situations: [], custom: 0 }; + } + function playerSheet(name) { return playerSheets[name]?.tabInfo || {}; } @@ -958,11 +981,7 @@ export default function MissionTab({ authedPlayer }) { if (!check || !state.player) return; if (!scene?.rollsUnlocked && hasMiniSkillRolled(sceneIndex, scene, state.player, check)) return; const feedSceneIndex = isGM ? sceneIndex : activeSceneIndex; - const targetBreakdown = targetBreakdownForPlayerCheck(state.player, check, { - difficulty: state.skillDifficultyModifier, - situations: state.skillSituationModifiers, - custom: state.skillCustomModifier, - }); + const targetBreakdown = targetBreakdownForPlayerCheck(state.player, check, skillModifierPayloadForScene(scene, state)); const target = targetBreakdown.target; const roll = d100(); const result = degrees(target, roll); @@ -1139,34 +1158,33 @@ export default function MissionTab({ authedPlayer }) { const state = miniState(sceneIndex, scene); const combatStateForScene = scene.combatState || {}; const initiatives = Array.isArray(combatStateForScene.initiatives) ? combatStateForScene.initiatives : []; - const selectedCheck = checks.find(check => check.name === state.checkName) || checks[0]; - const selectedPlayer = state.player || (isGM ? playerList[0] : authedPlayer); - const skillBreakdown = selectedCheck && selectedPlayer ? targetBreakdownForPlayerCheck(selectedPlayer, selectedCheck, { - difficulty: state.skillDifficultyModifier, - situations: state.skillSituationModifiers, - custom: state.skillCustomModifier, - }) : null; - const skillTarget = skillBreakdown?.target ?? null; - const skillAlreadyRolled = Boolean(selectedCheck && selectedPlayer && hasMiniSkillRolled(sceneIndex, scene, selectedPlayer, selectedCheck)); - const skillLocked = skillAlreadyRolled && !scene?.rollsUnlocked; - const feedSceneIndex = isGM ? sceneIndex : activeSceneIndex; - const selectedEnemy = enemies.find(enemy => enemy.name === state.enemyName) || enemies[0] || null; - const targetDead = Boolean(selectedEnemy?.dead || selectedEnemy?.defeated || Number(selectedEnemy?.wounds ?? selectedEnemy?.woundsTotal ?? selectedEnemy?.w ?? 1) <= 0); - const loggedInWounds = woundsLabel(playerSheet(authedPlayer).wounds); - const selectedPlayerWounds = woundsLabel(playerSheet(selectedPlayer).wounds); - const targetWounds = selectedEnemy ? woundsLabel(selectedEnemy.wounds ?? selectedEnemy.woundsTotal ?? selectedEnemy.w) : 'W ?'; - const latestSkillRoll = rollFeed.find(roll => - roll.scene_index === feedSceneIndex && - roll.roll_type === 'check' && - roll.payload?.mini && - (!selectedCheck || roll.label === selectedCheck.name) - ); - const latestCombatRoll = rollFeed.find(roll => - roll.scene_index === feedSceneIndex && - roll.roll_type === 'attack' && - roll.payload?.mini && - (!selectedEnemy || roll.payload?.enemy === selectedEnemy.name) - ); + const selectedCheck = checks.find(check => check.name === state.checkName) || checks[0]; + const selectedPlayer = state.player || (isGM ? playerList[0] : authedPlayer); + const skillModifiersVisible = playerCanSeeSkillConditions(scene); + const skillBreakdown = selectedCheck && selectedPlayer + ? targetBreakdownForPlayerCheck(selectedPlayer, selectedCheck, skillModifierPayloadForScene(scene, state)) + : null; + const skillTarget = skillBreakdown?.target ?? null; + const skillAlreadyRolled = Boolean(selectedCheck && selectedPlayer && hasMiniSkillRolled(sceneIndex, scene, selectedPlayer, selectedCheck)); + const skillLocked = skillAlreadyRolled && !scene?.rollsUnlocked; + const feedSceneIndex = isGM ? sceneIndex : activeSceneIndex; + const selectedEnemy = enemies.find(enemy => enemy.name === state.enemyName) || enemies[0] || null; + const targetDead = Boolean(selectedEnemy?.dead || selectedEnemy?.defeated || Number(selectedEnemy?.wounds ?? selectedEnemy?.woundsTotal ?? selectedEnemy?.w ?? 1) <= 0); + const loggedInWounds = woundsLabel(playerSheet(authedPlayer).wounds); + const selectedPlayerWounds = woundsLabel(playerSheet(selectedPlayer).wounds); + const targetWounds = selectedEnemy ? woundsLabel(selectedEnemy.wounds ?? selectedEnemy.woundsTotal ?? selectedEnemy.w) : 'W ?'; + const latestSkillRoll = rollFeed.find(roll => + roll.scene_index === feedSceneIndex && + roll.roll_type === 'check' && + roll.payload?.mini && + (!selectedCheck || roll.label === selectedCheck.name) + ); + const latestCombatRoll = rollFeed.find(roll => + roll.scene_index === feedSceneIndex && + roll.roll_type === 'attack' && + roll.payload?.mini && + (!selectedEnemy || roll.payload?.enemy === selectedEnemy.name) + ); return (
@@ -1194,14 +1212,19 @@ export default function MissionTab({ authedPlayer }) {
Skill
- + {isGM ? ( + + ) : ( +
+ {authedPlayer} +
+ )} -
- - -
- {SKILL_SITUATION_MODIFIERS.map(mod => { - const active = state.skillSituationModifiers.includes(mod.id); - return ( - - ); - })} + {isGM && ( +
+
+ + +
+ +
+ {SKILL_SITUATION_MODIFIERS.map(mod => { + const active = state.skillSituationModifiers.includes(mod.id); + const nextSituations = active + ? state.skillSituationModifiers.filter(id => id !== mod.id) + : [...state.skillSituationModifiers, mod.id]; + return ( + + ); + })} +
+
- -
+ )} {selectedCheck && (
@@ -1270,11 +1303,15 @@ export default function MissionTab({ authedPlayer }) {
Base {skillBreakdown.characteristic}: {skillBreakdown.base}
Training: {skillBreakdown.trainingLabel} ({skillBreakdown.training >= 0 ? '+' : ''}{skillBreakdown.training})
Scene modifier: {skillBreakdown.checkModifier >= 0 ? '+' : ''}{skillBreakdown.checkModifier}
-
Difficulty: {skillBreakdown.difficultyModifier >= 0 ? '+' : ''}{skillBreakdown.difficultyModifier}
-
Skill conditions: {skillBreakdown.situationTotal >= 0 ? '+' : ''}{skillBreakdown.situationTotal}
-
Custom: {skillBreakdown.customModifier >= 0 ? '+' : ''}{skillBreakdown.customModifier}
+ {skillModifiersVisible && ( + <> +
Difficulty: {skillBreakdown.difficultyModifier >= 0 ? '+' : ''}{skillBreakdown.difficultyModifier}
+
Skill conditions: {skillBreakdown.situationTotal >= 0 ? '+' : ''}{skillBreakdown.situationTotal}
+
Custom: {skillBreakdown.customModifier >= 0 ? '+' : ''}{skillBreakdown.customModifier}
+ + )}
- {skillBreakdown.situationMods.length > 0 ? ( + {skillModifiersVisible && skillBreakdown.situationMods.length > 0 ? (
{skillBreakdown.situationMods.map(mod => ( @@ -1282,8 +1319,10 @@ export default function MissionTab({ authedPlayer }) { ))}
- ) : ( + ) : skillModifiersVisible ? (
No active skill condition modifiers.
+ ) : ( +
Conditions hidden by GM.
)}
)} @@ -1845,9 +1884,9 @@ export default function MissionTab({ authedPlayer }) { )}
- {(isGM || sceneReveal.checks || sceneReveal.combat || currentScene?.rollsUnlocked) && ( - renderMiniSceneRoller(currentScene, activeMission.current_scene) - )} + {(isGM || Object.values(sceneReveal.checks || {}).some(Boolean) || sceneReveal.combat || currentScene?.rollsUnlocked) && ( + renderMiniSceneRoller(currentScene, activeMission.current_scene) + )} {sceneExtraChallenges.length > 0 && (isGM || sceneReveal.complications || sceneExtraChallenges.some(c => c.completed)) && (
diff --git a/src/tests/missionTab.test.js b/src/tests/missionTab.test.js index a1d994d..7e313c1 100644 --- a/src/tests/missionTab.test.js +++ b/src/tests/missionTab.test.js @@ -52,6 +52,20 @@ describe('MissionTab player view', () => { }, }); } + if (url === '/api/missions/active/rolls/feed?limit=50') return Promise.resolve({ data: [] }); + if (url === '/api/players') return Promise.resolve({ data: [{ name: 'anders' }] }); + if (url === '/api/players/anders') { + return Promise.resolve({ + data: { + name: 'anders', + tabInfo: { + characteristics: { Per: 40 }, + skills: { 'Awareness (Per)': { trained: true } }, + wounds: { current: 20, total: 20 }, + }, + }, + }); + } return Promise.resolve({ data: null }); }); }); @@ -80,6 +94,20 @@ describe('MissionTab player view', () => { expect(axios.get).not.toHaveBeenCalledWith('/api/missions'); expect(axios.get).not.toHaveBeenCalledWith('/api/missions/active/current'); }); + + test('does not show GM skill condition controls unless conditions are unlocked', async () => { + render(); + + await waitFor(() => expect(screen.getByText('Arrival at the Dead Station')).toBeInTheDocument()); + await waitFor(() => expect(screen.getAllByText('Mini Roller').length).toBeGreaterThan(0)); + + expect(screen.queryByLabelText('Skill difficulty')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Skill custom modifier')).not.toBeInTheDocument(); + expect(screen.queryByText('Show Conditions')).not.toBeInTheDocument(); + expect(screen.queryByText('Conditions Visible')).not.toBeInTheDocument(); + expect(screen.getAllByTestId('skill-target-number')[0]).toHaveTextContent('40'); + expect(screen.getByText('Conditions hidden by GM.')).toBeInTheDocument(); + }); }); describe('MissionTab mini skill modifiers', () => { @@ -114,6 +142,7 @@ describe('MissionTab mini skill modifiers', () => { } return Promise.resolve({ data: null }); }); + axios.put.mockResolvedValue({ data: { mission: activeMission } }); }); test('uses skill difficulty and skill condition modifiers for a mission skill check target', async () => { @@ -131,4 +160,21 @@ describe('MissionTab mini skill modifiers', () => { fireEvent.change(screen.getAllByLabelText('Skill custom modifier')[0], { target: { value: '-5' } }); expect(screen.getAllByTestId('skill-target-number')[0]).toHaveTextContent('55'); }); + + test('lets the GM unlock skill conditions for players', async () => { + render(); + + await waitFor(() => expect(screen.getAllByText('Mini Roller').length).toBeGreaterThan(0)); + + fireEvent.click(screen.getAllByText('Show Conditions')[0]); + + await waitFor(() => { + expect(axios.put).toHaveBeenCalledWith('/api/missions/9/progress', expect.objectContaining({ + currentScene: 0, + scenes: expect.arrayContaining([ + expect.objectContaining({ skillConditionsUnlocked: true }), + ]), + })); + }); + }); });