Gate player skill conditions in missions

This commit is contained in:
2026-06-30 09:07:55 +02:00
parent e7ef9ae2df
commit c54fd6c8fd
3 changed files with 185 additions and 95 deletions

View File

@@ -18,6 +18,11 @@ function playerScene(scene) {
completionText,
...safeScene
} = scene;
if (!safeScene.skillConditionsUnlocked) {
delete safeScene.skillDifficultyModifier;
delete safeScene.skillSituationModifiers;
delete safeScene.skillCustomModifier;
}
return safeScene;
}

View File

@@ -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 (
<div className={`rounded-lg border border-cyan-700/40 bg-cyan-950/10 p-4 ${opts.compact ? 'mt-3' : ''}`}>
@@ -1194,14 +1212,19 @@ export default function MissionTab({ authedPlayer }) {
<div className="rounded border border-slate-700 bg-slate-950/40 p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">Skill</div>
<div className="grid gap-2">
<select
value={state.player}
onChange={e => setMiniState(sceneIndex, { player: e.target.value, initPlayer: e.target.value })}
disabled={!isGM}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
>
{(isGM ? playerList : [authedPlayer]).map(name => <option key={name} value={name}>{name}</option>)}
</select>
{isGM ? (
<select
value={state.player}
onChange={e => setMiniState(sceneIndex, { player: e.target.value, initPlayer: e.target.value })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
>
{playerList.map(name => <option key={name} value={name}>{name}</option>)}
</select>
) : (
<div className="rounded border border-slate-700 bg-slate-900 px-2 py-2 text-sm capitalize text-slate-200">
{authedPlayer}
</div>
)}
<select
value={state.checkName}
onChange={e => setMiniState(sceneIndex, { checkName: e.target.value })}
@@ -1209,50 +1232,60 @@ export default function MissionTab({ authedPlayer }) {
>
{checks.map(check => <option key={check.name} value={check.name}>{check.name}</option>)}
</select>
<div className="grid gap-2 rounded border border-slate-800 bg-slate-900/50 p-2">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Skill difficulty</label>
<select
aria-label="Skill difficulty"
value={state.skillDifficultyModifier}
onChange={e => setMiniState(sceneIndex, { skillDifficultyModifier: Number(e.target.value) })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
>
{SKILL_DIFFICULTIES.map(diff => (
<option key={diff.label} value={diff.value}>
{diff.label} ({diff.value >= 0 ? '+' : ''}{diff.value})
</option>
))}
</select>
<div className="flex flex-wrap gap-1">
{SKILL_SITUATION_MODIFIERS.map(mod => {
const active = state.skillSituationModifiers.includes(mod.id);
return (
<button
key={mod.id}
type="button"
onClick={() => setMiniState(sceneIndex, {
skillSituationModifiers: active
? state.skillSituationModifiers.filter(id => id !== mod.id)
: [...state.skillSituationModifiers, mod.id],
})}
className={`rounded px-2 py-1 text-[11px] ${active ? 'bg-cyan-700 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'}`}
>
{mod.label} {mod.value >= 0 ? '+' : ''}{mod.value}
</button>
);
})}
{isGM && (
<div className="grid gap-2 rounded border border-slate-800 bg-slate-900/50 p-2">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Skill difficulty</label>
<button
type="button"
onClick={() => patchSceneAt(sceneIndex, { skillConditionsUnlocked: !scene?.skillConditionsUnlocked })}
className={`rounded px-2 py-1 text-[11px] ${scene?.skillConditionsUnlocked ? 'bg-emerald-700 text-white hover:bg-emerald-600' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'}`}
>
{scene?.skillConditionsUnlocked ? 'Conditions Visible' : 'Show Conditions'}
</button>
</div>
<select
aria-label="Skill difficulty"
value={state.skillDifficultyModifier}
onChange={e => setMiniSkillSettings(sceneIndex, { skillDifficultyModifier: Number(e.target.value) })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-2 text-sm"
>
{SKILL_DIFFICULTIES.map(diff => (
<option key={diff.label} value={diff.value}>
{diff.label} ({diff.value >= 0 ? '+' : ''}{diff.value})
</option>
))}
</select>
<div className="flex flex-wrap gap-1">
{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 (
<button
key={mod.id}
type="button"
onClick={() => setMiniSkillSettings(sceneIndex, { skillSituationModifiers: nextSituations })}
className={`rounded px-2 py-1 text-[11px] ${active ? 'bg-cyan-700 text-white' : 'bg-slate-800 text-slate-300 hover:bg-slate-700'}`}
>
{mod.label} {mod.value >= 0 ? '+' : ''}{mod.value}
</button>
);
})}
</div>
<label className="grid gap-1 text-xs text-slate-400">
Custom modifier
<input
aria-label="Skill custom modifier"
type="number"
value={state.skillCustomModifier}
onChange={e => setMiniSkillSettings(sceneIndex, { skillCustomModifier: Number(e.target.value || 0) })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
/>
</label>
</div>
<label className="grid gap-1 text-xs text-slate-400">
Custom modifier
<input
aria-label="Skill custom modifier"
type="number"
value={state.skillCustomModifier}
onChange={e => setMiniState(sceneIndex, { skillCustomModifier: Number(e.target.value || 0) })}
className="rounded border border-slate-600 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
/>
</label>
</div>
)}
{selectedCheck && (
<div className="rounded border border-slate-800 bg-slate-900/70 px-2 py-2 text-xs text-slate-300">
<div>
@@ -1270,11 +1303,15 @@ export default function MissionTab({ authedPlayer }) {
<div>Base {skillBreakdown.characteristic}: <span className="text-slate-200">{skillBreakdown.base}</span></div>
<div>Training: <span className="text-slate-200">{skillBreakdown.trainingLabel} ({skillBreakdown.training >= 0 ? '+' : ''}{skillBreakdown.training})</span></div>
<div>Scene modifier: <span className="text-slate-200">{skillBreakdown.checkModifier >= 0 ? '+' : ''}{skillBreakdown.checkModifier}</span></div>
<div>Difficulty: <span className="text-slate-200">{skillBreakdown.difficultyModifier >= 0 ? '+' : ''}{skillBreakdown.difficultyModifier}</span></div>
<div>Skill conditions: <span className="text-slate-200">{skillBreakdown.situationTotal >= 0 ? '+' : ''}{skillBreakdown.situationTotal}</span></div>
<div>Custom: <span className="text-slate-200">{skillBreakdown.customModifier >= 0 ? '+' : ''}{skillBreakdown.customModifier}</span></div>
{skillModifiersVisible && (
<>
<div>Difficulty: <span className="text-slate-200">{skillBreakdown.difficultyModifier >= 0 ? '+' : ''}{skillBreakdown.difficultyModifier}</span></div>
<div>Skill conditions: <span className="text-slate-200">{skillBreakdown.situationTotal >= 0 ? '+' : ''}{skillBreakdown.situationTotal}</span></div>
<div>Custom: <span className="text-slate-200">{skillBreakdown.customModifier >= 0 ? '+' : ''}{skillBreakdown.customModifier}</span></div>
</>
)}
</div>
{skillBreakdown.situationMods.length > 0 ? (
{skillModifiersVisible && skillBreakdown.situationMods.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{skillBreakdown.situationMods.map(mod => (
<span key={`${mod.label}-${mod.value}`} className="rounded bg-slate-800 px-1.5 py-0.5 text-[11px] text-slate-300">
@@ -1282,8 +1319,10 @@ export default function MissionTab({ authedPlayer }) {
</span>
))}
</div>
) : (
) : skillModifiersVisible ? (
<div className="mt-2 text-[11px] text-slate-500">No active skill condition modifiers.</div>
) : (
<div className="mt-2 text-[11px] text-slate-500">Conditions hidden by GM.</div>
)}
</div>
)}
@@ -1845,9 +1884,9 @@ export default function MissionTab({ authedPlayer }) {
)}
</div>
{(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)) && (
<div className="rounded-lg border border-amber-500/30 bg-amber-950/10 p-4">

View File

@@ -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(<MissionTab authedPlayer="anders" />);
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(<MissionTab authedPlayer="gm" />);
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 }),
]),
}));
});
});
});