Enhance mission management and gameplay interactions

- Introduce detailed logging for unit deaths during damage and chain damage events in GameLoop, improving feedback on combat outcomes.
- Update MissionManager to store completed mission details, ensuring procedural missions are accurately tracked and displayed in the UI.
- Modify TurnSystem to handle unit deaths from environmental hazards, enhancing gameplay realism and unit management.
- Improve MissionBoard to include completed missions that are no longer in the registry, ensuring players have visibility into past achievements.
- Add tests to validate new features and ensure integration with existing systems, enhancing overall game reliability.
This commit is contained in:
Matthew Mone 2026-01-02 09:13:16 -08:00
parent 0f4210d5c4
commit 8ac9fa441d
10 changed files with 315 additions and 138 deletions

View file

@ -939,7 +939,7 @@ export class GameLoop {
`${activeUnit.name} applied ${result.data.statusId} to ${target.name} for ${result.data.duration} turns`
);
} else if (result.data.type === "CHAIN_DAMAGE") {
// Log chain damage results
// Log chain damage results and check for deaths
if (result.data.results && result.data.results.length > 0) {
const primaryResult = result.data.results[0];
console.log(
@ -953,6 +953,54 @@ export class GameLoop {
`Chain lightning bounced to ${result.data.chainTargets.length} additional targets`
);
}
// Check for deaths on all targets (primary + chain targets)
for (const damageResult of result.data.results) {
if (damageResult.currentHP <= 0) {
const killedUnit = this.unitManager.getUnitById(
damageResult.target
);
if (killedUnit) {
console.log(`${killedUnit.name} has been defeated!`);
// Process ON_KILL passive effects (on source)
this.processPassiveItemEffects(activeUnit, "ON_KILL", {
target: killedUnit,
killedUnit: killedUnit,
});
// Handle unit death
this.handleUnitDeath(killedUnit);
}
} else {
// Process passive item effects for ON_DAMAGED trigger (on target)
const damagedUnit = this.unitManager.getUnitById(
damageResult.target
);
if (damagedUnit) {
this.processPassiveItemEffects(
damagedUnit,
"ON_DAMAGED",
{
source: activeUnit,
damageAmount: damageResult.amount,
}
);
}
}
// Process passive item effects for ON_DAMAGE_DEALT trigger (on source)
const damagedUnit = this.unitManager.getUnitById(
damageResult.target
);
if (damagedUnit) {
this.processPassiveItemEffects(
activeUnit,
"ON_DAMAGE_DEALT",
{
target: damagedUnit,
damageAmount: damageResult.amount,
}
);
}
}
}
}
}
@ -3097,6 +3145,42 @@ export class GameLoop {
unit.name
} (${trigger})`
);
// Check for death if this was a damage effect
if (result.data.type === "DAMAGE") {
if (result.data.currentHP <= 0 && target && typeof target === "object" && "currentHealth" in target) {
const killedUnit = /** @type {Unit} */ (target);
console.log(`${killedUnit.name} has been defeated by passive effect!`);
// Process ON_KILL passive effects (on source)
this.processPassiveItemEffects(unit, "ON_KILL", {
target: killedUnit,
killedUnit: killedUnit,
});
// Handle unit death
this.handleUnitDeath(killedUnit);
}
} else if (result.data.type === "CHAIN_DAMAGE") {
// Check for deaths on all targets (primary + chain targets)
if (result.data.results && result.data.results.length > 0) {
for (const damageResult of result.data.results) {
if (damageResult.currentHP <= 0) {
const killedUnit = this.unitManager.getUnitById(
damageResult.target
);
if (killedUnit) {
console.log(`${killedUnit.name} has been defeated by passive chain damage!`);
// Process ON_KILL passive effects (on source)
this.processPassiveItemEffects(unit, "ON_KILL", {
target: killedUnit,
killedUnit: killedUnit,
});
// Handle unit death
this.handleUnitDeath(killedUnit);
}
}
}
}
}
}
}
}

View file

@ -28,6 +28,8 @@ export class MissionManager {
this.completedMissions = new Set();
/** @type {Map<string, MissionDefinition>} */
this.missionRegistry = new Map();
/** @type {Map<string, MissionDefinition>} */
this.completedMissionDetails = new Map();
// Active Run State
/** @type {MissionDefinition | null} */
@ -147,16 +149,23 @@ export class MissionManager {
// Separate static missions from procedural missions
const staticMissions = [];
const proceduralMissions = [];
const completedProceduralMissions = [];
for (const mission of this.missionRegistry.values()) {
if (mission.type === "SIDE_QUEST" && mission.id?.startsWith("SIDE_OP_")) {
proceduralMissions.push(mission);
// Keep completed procedural missions in the registry so they show in completed tab
if (this.completedMissions.has(mission.id)) {
completedProceduralMissions.push(mission);
} else {
proceduralMissions.push(mission);
}
} else {
staticMissions.push(mission);
}
}
// Refresh procedural missions (refreshBoard fills up to 5 missions)
// Only pass non-completed procedural missions to refresh
const refreshedProcedural = MissionGenerator.refreshBoard(
proceduralMissions,
tier,
@ -165,7 +174,7 @@ export class MissionManager {
isDailyReset
);
// Remove old procedural missions from registry
// Remove old non-completed procedural missions from registry
proceduralMissions.forEach((mission) => {
this.missionRegistry.delete(mission.id);
});
@ -251,6 +260,15 @@ export class MissionManager {
*/
load(saveData) {
this.completedMissions = new Set(saveData.completedMissions || []);
// Restore completed mission details (especially important for procedural missions)
this.completedMissionDetails = new Map();
if (saveData.completedMissionDetails) {
for (const [missionId, missionDef] of Object.entries(
saveData.completedMissionDetails
)) {
this.completedMissionDetails.set(missionId, missionDef);
}
}
// Default to Tutorial if history is empty
if (this.completedMissions.size === 0) {
this.activeMissionId = "MISSION_TUTORIAL_01";
@ -262,8 +280,18 @@ export class MissionManager {
* @returns {MissionSaveData} - Serialized campaign data
*/
save() {
// Convert completed mission details Map to object for serialization
const completedMissionDetailsObj = {};
for (const [
missionId,
missionDef,
] of this.completedMissionDetails.entries()) {
completedMissionDetailsObj[missionId] = missionDef;
}
return {
completedMissions: Array.from(this.completedMissions),
completedMissionDetails: completedMissionDetailsObj,
};
}
@ -482,7 +510,7 @@ export class MissionManager {
// For unmapped IDs, convert NARRATIVE_XYZ -> narrative_xyz
// Keep the "narrative_" prefix but lowercase everything
return narrativeId.toLowerCase().replace("narrative_", "");
return narrativeId.toLowerCase();
}
// --- GAMEPLAY LOGIC (Objectives) ---
@ -515,6 +543,8 @@ export class MissionManager {
console.log(
`[MissionManager] ENEMY_DEATH event received, checking ELIMINATE_ALL objective`
);
// Mark that we had enemies since we received an ENEMY_DEATH event
this._hadEnemies = true;
statusChanged = this.checkEliminateAllObjective() || statusChanged;
console.log(
`[MissionManager] ELIMINATE_ALL check returned statusChanged: ${statusChanged}`
@ -831,6 +861,22 @@ export class MissionManager {
// Mark mission as completed
this.completedMissions.add(this.activeMissionId);
// Save mission details for procedural missions (they get removed from registry on refresh)
// This ensures we can display them in the completed missions tab
if (
this.currentMissionDef.type === "SIDE_QUEST" &&
this.activeMissionId?.startsWith("SIDE_OP_")
) {
// Deep clone the mission definition to preserve it
const missionClone = JSON.parse(JSON.stringify(this.currentMissionDef));
this.completedMissionDetails.set(this.activeMissionId, missionClone);
console.log(
"MissionManager: Saved procedural mission details for:",
this.activeMissionId
);
}
console.log(
"MissionManager: Mission completed. Active mission ID:",
this.activeMissionId

View file

@ -49,6 +49,7 @@ export interface Objective {
*/
export interface MissionSaveData {
completedMissions: string[];
completedMissionDetails?: Record<string, MissionDefinition>;
}
/**

View file

@ -173,10 +173,18 @@ export class TurnSystem extends EventTarget {
// C. Environmental Hazard Processing (Integration Point 3)
// Process hazards BEFORE status effects (hazards are environmental, status effects are on the unit)
this.processEnvironmentalHazards(unit);
let unitDied = this.processEnvironmentalHazards(unit);
// Check if unit died from environmental hazards - if so, handle death and skip turn
if (unitDied && this.onUnitDeathCallback) {
this.onUnitDeathCallback(unit);
// Skip turn for dead unit
this.endTurn(unit);
return;
}
// D. Status Effect Tick (The "Upkeep" Step)
const unitDied = this.processStatusEffects(unit);
unitDied = this.processStatusEffects(unit);
// Check if unit died from status effects - if so, handle death and skip turn
if (unitDied && this.onUnitDeathCallback) {
@ -206,16 +214,17 @@ export class TurnSystem extends EventTarget {
* Processes environmental hazards at the unit's position.
* Integration Point 3: Environmental Hazard
* @param {Unit} unit - The unit to check hazards for
* @returns {boolean} True if the unit died from environmental hazards
* @private
*/
processEnvironmentalHazards(unit) {
if (!this.voxelGrid || !this.effectProcessor || !unit.position) {
return;
return false;
}
const hazard = this.voxelGrid.getHazardAt(unit.position);
if (!hazard) {
return;
return false;
}
// Map hazard IDs to effect definitions
@ -248,6 +257,7 @@ export class TurnSystem extends EventTarget {
// Process hazard damage through EffectProcessor
// Source is null (environmental), target is the unit
const healthBefore = unit.currentHealth;
const result = this.effectProcessor.process(effectDef, null, unit);
if (result.success && result.data && result.data.type === "DAMAGE") {
console.log(
@ -257,6 +267,9 @@ export class TurnSystem extends EventTarget {
);
}
// Check if unit died from hazard damage
const unitDied = healthBefore > 0 && unit.currentHealth <= 0;
// Decrement hazard duration
hazard.duration -= 1;
if (hazard.duration <= 0) {
@ -264,6 +277,8 @@ export class TurnSystem extends EventTarget {
const key = `${unit.position.x},${unit.position.y},${unit.position.z}`;
this.voxelGrid.hazardMap.delete(key);
}
return unitDied;
}
/**

View file

@ -250,6 +250,7 @@ export class MissionBoard extends LitElement {
connectedCallback() {
super.connectedCallback();
// Load missions and refresh procedural missions only on first open
// Ensure we wait for GameStateManager to be ready (important after page refresh)
this._initialLoad();
// Listen for campaign data changes to refresh completed missions
@ -272,6 +273,9 @@ export class MissionBoard extends LitElement {
try {
// Ensure missions are loaded before accessing registry
await gameStateManager.missionManager._ensureMissionsLoaded();
// Wait a bit to ensure campaign data has been loaded from persistence
// This is important after page refresh when persistence loads campaign data asynchronously
await new Promise(resolve => setTimeout(resolve, 50));
// Refresh procedural missions if unlocked (to ensure board is populated on first open)
// This only happens once when the board is first opened
if (gameStateManager.missionManager.areProceduralMissionsUnlocked()) {
@ -297,6 +301,8 @@ export class MissionBoard extends LitElement {
_handleCampaignChange() {
// Reload missions when campaign data changes (mission completed)
// Force reload of completed missions from MissionManager
this.completedMissions = gameStateManager.missionManager.completedMissions || new Set();
this._loadMissions();
}
@ -331,7 +337,25 @@ export class MissionBoard extends LitElement {
// Get all registered missions from MissionManager
const missionRegistry = gameStateManager.missionManager.missionRegistry;
this.missions = Array.from(missionRegistry.values());
this.completedMissions = gameStateManager.missionManager.completedMissions || new Set();
// Also include completed mission details (for procedural missions that were removed from registry)
const completedMissionDetails = gameStateManager.missionManager.completedMissionDetails || new Map();
for (const [missionId, missionDef] of completedMissionDetails.entries()) {
// Only add if not already in registry (to avoid duplicates)
if (!this.missions.find(m => m.id === missionId)) {
this.missions.push(missionDef);
}
}
// Always refresh completed missions from MissionManager to ensure we have the latest data
// This is important after page refresh when persistence has loaded the data
const managerCompletedMissions = gameStateManager.missionManager.completedMissions;
if (managerCompletedMissions instanceof Set) {
this.completedMissions = new Set(managerCompletedMissions);
} else {
this.completedMissions = new Set(managerCompletedMissions || []);
}
console.log('MissionBoard: Loaded completed missions:', Array.from(this.completedMissions));
this.requestUpdate();
}
@ -452,20 +476,71 @@ export class MissionBoard extends LitElement {
}
_getCompletedMissions() {
return this.missions.filter(mission => {
// Get completed missions that are in the current registry
const completedInRegistry = this.missions.filter(mission => {
if (!this._shouldShowMission(mission)) {
return false;
}
return this._isMissionCompleted(mission.id);
});
// Also include completed missions that are no longer in the registry
// This is important for procedural missions (side quests) that get refreshed
const completedNotInRegistry = [];
for (const missionId of this.completedMissions) {
// Check if this mission is not in the current registry
const missionInRegistry = this.missions.find(m => m.id === missionId);
if (!missionInRegistry) {
// Create a stub mission object for display purposes
// This handles procedural missions that were removed during refresh
const stubMission = this._createStubMission(missionId);
if (stubMission) {
completedNotInRegistry.push(stubMission);
}
}
}
return [...completedInRegistry, ...completedNotInRegistry];
}
_createStubMission(missionId) {
// First, check if we have saved mission details from persistence
const completedMissionDetails = gameStateManager.missionManager.completedMissionDetails || new Map();
const savedMission = completedMissionDetails.get(missionId);
if (savedMission) {
return savedMission;
}
// Fallback: Create a minimal mission object for completed missions that are no longer in the registry
// This is primarily for procedural side quests that get refreshed
if (missionId.startsWith('SIDE_OP_')) {
// It's a procedural side quest - create a stub
return {
id: missionId,
type: 'SIDE_QUEST',
config: {
title: missionId.replace('SIDE_OP_', '').replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
description: 'Completed procedural mission (no longer available)',
},
rewards: {
guaranteed: {
xp: 0,
currency: { aether_shards: 0, ancient_cores: 0 },
},
},
};
}
// For non-procedural missions, try to load from static mission files if possible
// For now, return null - we could enhance this later to load from mission files
return null;
}
_getCompletedMissionsByType() {
const completed = this._getCompletedMissions();
return {
story: completed.filter(m => m.type === 'STORY'),
story: completed.filter(m => m.type === 'STORY' || m.type === 'TUTORIAL'),
sideQuest: completed.filter(m => m.type === 'SIDE_QUEST'),
other: completed.filter(m => m.type !== 'STORY' && m.type !== 'SIDE_QUEST'),
other: completed.filter(m => m.type !== 'STORY' && m.type !== 'SIDE_QUEST' && m.type !== 'TUTORIAL'),
};
}

View file

@ -77,6 +77,8 @@ export function createMockMissionManager(enemySpawns = []) {
return {
getActiveMission: sinon.stub().returns(mockMissionDef),
setGridContext: sinon.stub(),
populateZoneCoordinates: sinon.stub(),
};
}

View file

@ -128,9 +128,9 @@ describe("Manager: MissionManager", () => {
},
];
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_GOBLIN" });
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_OTHER" }); // Should not count
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_GOBLIN" });
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_GOBLIN", defId: "ENEMY_GOBLIN" });
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_OTHER", defId: "ENEMY_OTHER" }); // Should not count
manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_GOBLIN", defId: "ENEMY_GOBLIN" });
expect(manager.currentObjectives[0].current).to.equal(2);
expect(manager.currentObjectives[0].complete).to.be.true;

View file

@ -221,7 +221,10 @@ describe("Systems: MissionGenerator", function () {
if (mission.objectives.primary[0].type === "REACH_ZONE") {
foundRecon = true;
expect(mission.objectives.primary[0].target_count).to.equal(3);
expect(mission.objectives.failure_conditions).to.deep.include({ type: "TURN_LIMIT_EXCEEDED" });
const hasTurnLimit = mission.objectives.failure_conditions.some(
(fc) => fc.type === "TURN_LIMIT_EXCEEDED"
);
expect(hasTurnLimit).to.be.true;
}
}
expect(foundRecon).to.be.true;
@ -355,12 +358,26 @@ describe("Systems: MissionGenerator", function () {
const unlockedRegions = ["BIOME_RUSTING_WASTES"];
it("CoA 19: Should calculate currency with tier multiplier and random factor", () => {
const mission = MissionGenerator.generateSideOp(3, unlockedRegions, []);
// Generate multiple missions to account for different archetypes
// Non-assassination missions: Base 50 * 2.5 (tier 3) * random(0.8, 1.2) = 100-150 range
// Assassination missions get 1.5x bonus: 150-225 range
let foundNonAssassination = false;
for (let i = 0; i < 20 && !foundNonAssassination; i++) {
const mission = MissionGenerator.generateSideOp(3, unlockedRegions, []);
const currency = mission.rewards.guaranteed.currency.aether_shards;
const isAssassination = mission.objectives.primary[0].type === "ELIMINATE_UNIT";
// Base 50 * 2.5 (tier 3) * random(0.8, 1.2) = 100-150 range
const currency = mission.rewards.guaranteed.currency.aether_shards;
expect(currency).to.be.at.least(100); // 50 * 2.5 * 0.8 = 100
expect(currency).to.be.at.most(150); // 50 * 2.5 * 1.2 = 150
if (!isAssassination) {
foundNonAssassination = true;
expect(currency).to.be.at.least(100); // 50 * 2.5 * 0.8 = 100
expect(currency).to.be.at.most(150); // 50 * 2.5 * 1.2 = 150
} else {
// Assassination missions get 1.5x bonus
expect(currency).to.be.at.least(150); // 50 * 2.5 * 0.8 * 1.5 = 150
expect(currency).to.be.at.most(225); // 50 * 2.5 * 1.2 * 1.5 = 225
}
}
expect(foundNonAssassination).to.be.true;
});
it("CoA 20: Should give bonus currency for Assassination missions", () => {

View file

@ -128,109 +128,6 @@ describe("UI: BarracksScreen", () => {
// Wait for element to be defined and connected
await element.updateComplete;
// Create mock hub stash
mockHubStash = {
currency: {
aetherShards: 1000,
ancientCores: 0,
},
};
// Create mock persistence
mockPersistence = {
loadRun: sinon.stub().resolves({
inventory: {
runStash: {
currency: {
aetherShards: 500,
ancientCores: 0,
},
},
},
}),
saveRoster: sinon.stub().resolves(),
saveHubStash: sinon.stub().resolves(),
};
// Create mock class registry
const mockClassRegistry = new Map();
mockClassRegistry.set("CLASS_VANGUARD", vanguardDef);
// Create mock game loop with class registry
mockGameLoop = {
classRegistry: mockClassRegistry,
};
// Create mock roster with test units
const testRoster = [
{
id: "UNIT_1",
name: "Valerius",
classId: "CLASS_VANGUARD",
activeClassId: "CLASS_VANGUARD",
status: "READY",
classMastery: {
CLASS_VANGUARD: {
level: 3,
xp: 150,
skillPoints: 2,
unlockedNodes: [],
},
},
history: { missions: 2, kills: 5 },
},
{
id: "UNIT_2",
name: "Aria",
classId: "CLASS_VANGUARD",
activeClassId: "CLASS_VANGUARD",
status: "INJURED",
currentHealth: 60, // Injured unit with stored HP
classMastery: {
CLASS_VANGUARD: {
level: 2,
xp: 80,
skillPoints: 1,
unlockedNodes: [],
},
},
history: { missions: 1, kills: 2 },
},
{
id: "UNIT_3",
name: "Kael",
classId: "CLASS_VANGUARD",
activeClassId: "CLASS_VANGUARD",
status: "READY",
classMastery: {
CLASS_VANGUARD: {
level: 5,
xp: 300,
skillPoints: 3,
unlockedNodes: [],
},
},
history: { missions: 5, kills: 12 },
},
];
// Create mock roster manager
mockRosterManager = {
roster: testRoster,
rosterLimit: 12,
getDeployableUnits: sinon.stub().returns(testRoster.filter((u) => u.status === "READY")),
save: sinon.stub().returns({
roster: testRoster,
graveyard: [],
}),
};
// Replace gameStateManager properties with mocks
gameStateManager.persistence = mockPersistence;
gameStateManager.rosterManager = mockRosterManager;
gameStateManager.hubStash = mockHubStash;
gameStateManager.gameLoop = mockGameLoop;
});
afterEach(() => {

View file

@ -9,19 +9,21 @@ describe("UI: MissionBoard", () => {
let mockMissionManager;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
element = document.createElement("mission-board");
container.appendChild(element);
// Mock MissionManager
// Mock MissionManager - set up BEFORE creating element
mockMissionManager = {
missionRegistry: new Map(),
completedMissions: new Set(),
_ensureMissionsLoaded: sinon.stub().resolves(), // Mock lazy loading
areProceduralMissionsUnlocked: sinon.stub().returns(false),
refreshProceduralMissions: sinon.stub(),
};
gameStateManager.missionManager = mockMissionManager;
container = document.createElement("div");
document.body.appendChild(container);
element = document.createElement("mission-board");
container.appendChild(element);
});
afterEach(() => {
@ -89,6 +91,10 @@ describe("UI: MissionBoard", () => {
mockMissionManager.missionRegistry.set(mission1.id, mission1);
mockMissionManager.missionRegistry.set(mission2.id, mission2);
// Wait for initial load to complete, then trigger a reload
await new Promise(resolve => setTimeout(resolve, 100));
// Dispatch event to trigger reload
window.dispatchEvent(new CustomEvent('missions-updated'));
await waitForUpdate();
const missionCards = queryShadowAll(".mission-card");
@ -267,9 +273,19 @@ describe("UI: MissionBoard", () => {
};
mockMissionManager.missionRegistry.set(mission.id, mission);
mockMissionManager.completedMissions.add(mission.id);
await new Promise(resolve => setTimeout(resolve, 100));
window.dispatchEvent(new CustomEvent('missions-updated'));
await waitForUpdate();
// Switch to completed tab
const completedTab = queryShadow('.tab-button:last-child');
if (completedTab) {
completedTab.click();
await waitForUpdate();
}
const missionCard = queryShadow(".mission-card");
expect(missionCard).to.exist;
expect(missionCard.classList.contains("completed")).to.be.true;
expect(missionCard.textContent).to.include("Completed");
});
@ -283,9 +299,19 @@ describe("UI: MissionBoard", () => {
};
mockMissionManager.missionRegistry.set(mission.id, mission);
mockMissionManager.completedMissions.add(mission.id);
await new Promise(resolve => setTimeout(resolve, 100));
window.dispatchEvent(new CustomEvent('missions-updated'));
await waitForUpdate();
// Switch to completed tab
const completedTab = queryShadow('.tab-button:last-child');
if (completedTab) {
completedTab.click();
await waitForUpdate();
}
const missionCard = queryShadow(".mission-card");
expect(missionCard).to.exist;
const selectButton = missionCard.querySelector(".select-button");
expect(selectButton).to.be.null;
});
@ -600,15 +626,29 @@ describe("UI: MissionBoard", () => {
mockMissionManager.missionRegistry.set(mission1.id, mission1);
mockMissionManager.missionRegistry.set(mission2.id, mission2);
mockMissionManager.completedMissions.add("MISSION_01");
await new Promise(resolve => setTimeout(resolve, 100));
window.dispatchEvent(new CustomEvent('missions-updated'));
await waitForUpdate();
// Wait a bit more for the component to process the update
await new Promise(resolve => setTimeout(resolve, 50));
await waitForUpdate();
const missionCards = queryShadowAll(".mission-card");
expect(missionCards.length).to.equal(2);
const titles = Array.from(missionCards).map((card) =>
card.querySelector(".mission-title")?.textContent.trim()
);
expect(titles).to.include("First Story");
expect(titles).to.include("Second Story");
// Check active tab - should show MISSION_02 (prerequisites met)
const activeCards = queryShadowAll(".missions-grid .mission-card");
expect(activeCards.length).to.equal(1);
const activeTitle = activeCards[0].querySelector(".mission-title")?.textContent.trim();
expect(activeTitle).to.equal("Second Story");
// Check completed tab - should show MISSION_01 (completed)
const completedTab = queryShadow('.tab-button:last-child');
if (completedTab) {
completedTab.click();
await waitForUpdate();
const completedCards = queryShadowAll(".missions-grid .mission-card");
expect(completedCards.length).to.equal(1);
const completedTitle = completedCards[0].querySelector(".mission-title")?.textContent.trim();
expect(completedTitle).to.equal("First Story");
}
});
it("should respect explicit visibility_when_locked setting", async () => {