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 }),
+ ]),
+ }));
+ });
+ });
});