Gate player skill conditions in missions
This commit is contained in:
@@ -18,6 +18,11 @@ function playerScene(scene) {
|
||||
completionText,
|
||||
...safeScene
|
||||
} = scene;
|
||||
if (!safeScene.skillConditionsUnlocked) {
|
||||
delete safeScene.skillDifficultyModifier;
|
||||
delete safeScene.skillSituationModifiers;
|
||||
delete safeScene.skillCustomModifier;
|
||||
}
|
||||
return safeScene;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }),
|
||||
]),
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user