feat: sequential zone unlocking with dual boss+quest gate conditions

Zones now unlock in strict linear order. Each zone requires both the
final boss AND the final quest of the previous zone to be completed:

Verdant Vale → Shattered Ruins (forest_giant + ancient_ruins)
Shattered Ruins → Frozen Peaks (elder_dragon + dragon_lair)
Frozen Peaks → Shadow Marshes (void_titan + storm_citadel)
Shadow Marshes → Volcanic Depths (mud_kraken + plague_ruins)
Volcanic Depths → Astral Void (phoenix_lord + the_forge)

- Zone type gains `unlockQuestId` field alongside `unlockBossId`
- boss.ts checks both conditions before unlocking zone on boss defeat
- tick.ts checks both conditions and unlocks zones + first boss on
  quest completion if the boss condition is already met
- GameContext optimistic update also respects dual-condition logic
- BossPanel zone-gate hints now show both "⚔️ Defeat: X & 📜 Complete: Y"
- game.ts backfill syncs unlockQuestId and re-verifies zone status
  using both conditions, reverting incorrectly-unlocked saves
This commit is contained in:
2026-03-06 16:04:52 -08:00
committed by Naomi Carrigan
parent b9a230f40f
commit e780dc5f6c
7 changed files with 160 additions and 39 deletions
+18 -12
View File
@@ -9,6 +9,7 @@ export const DEFAULT_ZONES: Zone[] = [
emoji: "🌿", emoji: "🌿",
status: "unlocked", status: "unlocked",
unlockBossId: null, unlockBossId: null,
unlockQuestId: null,
}, },
{ {
id: "shattered_ruins", id: "shattered_ruins",
@@ -17,16 +18,8 @@ export const DEFAULT_ZONES: Zone[] = [
"The remnants of a civilisation long lost to war and dragonfire. Crumbling towers and cursed lakes hide treasures — and an elder dragon who claims these lands as his own.", "The remnants of a civilisation long lost to war and dragonfire. Crumbling towers and cursed lakes hide treasures — and an elder dragon who claims these lands as his own.",
emoji: "🏛️", emoji: "🏛️",
status: "locked", status: "locked",
unlockBossId: "lich_queen", unlockBossId: "forest_giant",
}, unlockQuestId: "ancient_ruins",
{
id: "shadow_marshes",
name: "The Shadow Marshes",
description:
"A vast, fog-choked wetland where the sun never fully rises. Dark magic seeps from the earth itself, and things far older than the kingdom lurk beneath the murky waters.",
emoji: "🌑",
status: "locked",
unlockBossId: "troll_king",
}, },
{ {
id: "frozen_peaks", id: "frozen_peaks",
@@ -36,6 +29,17 @@ export const DEFAULT_ZONES: Zone[] = [
emoji: "❄️", emoji: "❄️",
status: "locked", status: "locked",
unlockBossId: "elder_dragon", unlockBossId: "elder_dragon",
unlockQuestId: "dragon_lair",
},
{
id: "shadow_marshes",
name: "The Shadow Marshes",
description:
"A vast, fog-choked wetland where the sun never fully rises. Dark magic seeps from the earth itself, and things far older than the kingdom lurk beneath the murky waters.",
emoji: "🌑",
status: "locked",
unlockBossId: "void_titan",
unlockQuestId: "storm_citadel",
}, },
{ {
id: "volcanic_depths", id: "volcanic_depths",
@@ -44,7 +48,8 @@ export const DEFAULT_ZONES: Zone[] = [
"A chain of active volcanoes whose caverns plunge deep into the earth's molten heart. Legendary forges burn here, tended by fire elementals who serve no master — yet.", "A chain of active volcanoes whose caverns plunge deep into the earth's molten heart. Legendary forges burn here, tended by fire elementals who serve no master — yet.",
emoji: "🌋", emoji: "🌋",
status: "locked", status: "locked",
unlockBossId: "bone_colossus", unlockBossId: "mud_kraken",
unlockQuestId: "plague_ruins",
}, },
{ {
id: "astral_void", id: "astral_void",
@@ -53,6 +58,7 @@ export const DEFAULT_ZONES: Zone[] = [
"Beyond the veil of the mortal world lies a realm of pure possibility and absolute terror. Stars are born and die here in moments, and the beings that call this place home have never known mortality.", "Beyond the veil of the mortal world lies a realm of pure possibility and absolute terror. Stars are born and die here in moments, and the beings that call this place home have never known mortality.",
emoji: "🌌", emoji: "🌌",
status: "locked", status: "locked",
unlockBossId: "void_titan", unlockBossId: "phoenix_lord",
unlockQuestId: "the_forge",
}, },
]; ];
+9 -3
View File
@@ -155,9 +155,16 @@ bossRouter.post("/challenge", async (context) => {
if (nextBossInState) nextBossInState.status = "available"; if (nextBossInState) nextBossInState.status = "available";
} }
// Unlock any zone whose unlock condition is this boss, and activate its first boss // Unlock any zone whose unlock conditions are now both satisfied
// (final boss defeated AND final quest completed)
for (const zone of (state.zones ?? [])) { for (const zone of (state.zones ?? [])) {
if (zone.unlockBossId === body.bossId) { if (zone.status === "unlocked") continue;
if (zone.unlockBossId !== body.bossId) continue;
// Boss condition just became satisfied — check the quest condition too
const questSatisfied =
zone.unlockQuestId == null ||
state.quests.some((q) => q.id === zone.unlockQuestId && q.status === "completed");
if (!questSatisfied) continue;
zone.status = "unlocked"; zone.status = "unlocked";
const newZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id); const newZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id);
const firstNewBoss = newZoneBosses[0]; const firstNewBoss = newZoneBosses[0];
@@ -165,7 +172,6 @@ bossRouter.post("/challenge", async (context) => {
firstNewBoss.status = "available"; firstNewBoss.status = "available";
} }
} }
}
rewards = { rewards = {
gold: boss.goldReward, gold: boss.goldReward,
+55 -8
View File
@@ -98,6 +98,14 @@ gameRouter.get("/load", async (context) => {
quest.rewards = structuredClone(defaults.rewards); quest.rewards = structuredClone(defaults.rewards);
needsBackfill = true; needsBackfill = true;
} }
// Revert "available" quests back to "locked" if their zone is still locked
if (quest.status === "available") {
const zone = state.zones.find((z) => z.id === quest.zoneId);
if (zone?.status === "locked") {
quest.status = "locked";
needsBackfill = true;
}
}
// Retroactively apply adventurer unlocks from already-completed quests // Retroactively apply adventurer unlocks from already-completed quests
if (quest.status === "completed") { if (quest.status === "completed") {
for (const reward of quest.rewards) { for (const reward of quest.rewards) {
@@ -135,14 +143,23 @@ gameRouter.get("/load", async (context) => {
} }
} }
const zoneUnlockConditionsMet = (zone: { unlockBossId: string | null; unlockQuestId: string | null }): boolean => {
const bossOk =
zone.unlockBossId == null ||
state.bosses.some((b) => b.id === zone.unlockBossId && b.status === "defeated");
const questOk =
zone.unlockQuestId == null ||
state.quests.some((q) => q.id === zone.unlockQuestId && q.status === "completed");
return bossOk && questOk;
};
// Backfill zones // Backfill zones
if (!Array.isArray(state.zones) || state.zones.length === 0) { if (!Array.isArray(state.zones) || state.zones.length === 0) {
state.zones = structuredClone(DEFAULT_ZONES); state.zones = structuredClone(DEFAULT_ZONES);
// Infer unlock state from defeated bosses // Infer unlock state from current boss + quest completion
for (const zone of state.zones) { for (const zone of state.zones) {
if (zone.unlockBossId != null) { if (zone.unlockBossId != null || zone.unlockQuestId != null) {
const unlockBoss = state.bosses.find((b) => b.id === zone.unlockBossId); if (zoneUnlockConditionsMet(zone)) {
if (unlockBoss?.status === "defeated") {
zone.status = "unlocked"; zone.status = "unlocked";
} }
} }
@@ -153,10 +170,9 @@ gameRouter.get("/load", async (context) => {
for (const defaultZone of DEFAULT_ZONES) { for (const defaultZone of DEFAULT_ZONES) {
if (!state.zones.some((z) => z.id === defaultZone.id)) { if (!state.zones.some((z) => z.id === defaultZone.id)) {
const newZone = structuredClone(defaultZone); const newZone = structuredClone(defaultZone);
// Infer unlock state from defeated bosses // Infer unlock state from current boss + quest completion
if (newZone.unlockBossId != null) { if (newZone.unlockBossId != null || newZone.unlockQuestId != null) {
const unlockBoss = state.bosses.find((b) => b.id === newZone.unlockBossId); if (zoneUnlockConditionsMet(newZone)) {
if (unlockBoss?.status === "defeated") {
newZone.status = "unlocked"; newZone.status = "unlocked";
} }
} }
@@ -166,6 +182,37 @@ gameRouter.get("/load", async (context) => {
} }
} }
// Sync unlockBossId and unlockQuestId from defaults in case zone gate requirements changed
for (const zone of state.zones) {
const defaultZone = DEFAULT_ZONES.find((z) => z.id === zone.id);
if (!defaultZone) continue;
if (zone.unlockBossId !== defaultZone.unlockBossId) {
zone.unlockBossId = defaultZone.unlockBossId;
needsBackfill = true;
}
if (!("unlockQuestId" in zone) || zone.unlockQuestId !== defaultZone.unlockQuestId) {
zone.unlockQuestId = defaultZone.unlockQuestId;
needsBackfill = true;
}
}
// Re-verify zone unlock status against current unlock conditions
// (handles cases where gate requirements changed in a data update)
for (const zone of state.zones) {
if (zone.unlockBossId == null && zone.unlockQuestId == null) continue;
if (zone.status === "unlocked" && !zoneUnlockConditionsMet(zone)) {
zone.status = "locked";
// Revert any "available" bosses in this zone back to "locked"
for (const boss of state.bosses) {
if (boss.zoneId === zone.id && boss.status === "available") {
boss.status = "locked";
needsBackfill = true;
}
}
needsBackfill = true;
}
}
// Backfill zoneId and sync rewards on bosses to match current defaults // Backfill zoneId and sync rewards on bosses to match current defaults
for (const boss of state.bosses) { for (const boss of state.bosses) {
const defaults = DEFAULT_BOSSES.find((d) => d.id === boss.id); const defaults = DEFAULT_BOSSES.find((d) => d.id === boss.id);
+12 -4
View File
@@ -157,12 +157,20 @@ export const BossPanel = (): React.JSX.Element => {
for (let i = 0; i < allZoneBosses.length; i++) { for (let i = 0; i < allZoneBosses.length; i++) {
const boss = allZoneBosses[i]; const boss = allZoneBosses[i];
if (!boss || boss.status !== "locked") continue; if (!boss || boss.status !== "locked") continue;
if (i === 0 && zone.unlockBossId) { if (i === 0) {
const parts: string[] = [];
if (zone.unlockBossId) {
const gateBoss = state.bosses.find((b) => b.id === zone.unlockBossId); const gateBoss = state.bosses.find((b) => b.id === zone.unlockBossId);
if (gateBoss) { if (gateBoss) parts.push(`⚔️ Defeat: ${gateBoss.name}`);
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${gateBoss.name}`);
} }
} else if (i > 0) { if (zone.unlockQuestId) {
const gateQuest = state.quests.find((q) => q.id === zone.unlockQuestId);
if (gateQuest) parts.push(`📜 Complete: ${gateQuest.name}`);
}
if (parts.length > 0) {
bossUnlockHints.set(boss.id, parts.join(" & "));
}
} else {
const prevBoss = allZoneBosses[i - 1]; const prevBoss = allZoneBosses[i - 1];
if (prevBoss) { if (prevBoss) {
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${prevBoss.name} first`); bossUnlockHints.set(boss.id, `⚔️ Defeat: ${prevBoss.name} first`);
+15 -4
View File
@@ -279,7 +279,14 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
const nextZoneBossId = zoneBosses[zoneIdx + 1]?.id; const nextZoneBossId = zoneBosses[zoneIdx + 1]?.id;
// Find newly unlocked zones and their first bosses // Find newly unlocked zones and their first bosses
const newlyUnlockedZones = (prev.zones ?? []).filter((z) => z.unlockBossId === bossId && z.status === "locked"); // A zone unlocks when BOTH the gate boss is defeated AND the gate quest is completed
const newlyUnlockedZones = (prev.zones ?? []).filter((z) => {
if (z.status !== "locked" || z.unlockBossId !== bossId) return false;
const questOk =
z.unlockQuestId == null ||
prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed");
return questOk;
});
const newZoneFirstBossIds = newlyUnlockedZones.map((z) => { const newZoneFirstBossIds = newlyUnlockedZones.map((z) => {
const firstBoss = prev.bosses.find((b) => b.zoneId === z.id); const firstBoss = prev.bosses.find((b) => b.zoneId === z.id);
return firstBoss?.id; return firstBoss?.id;
@@ -297,9 +304,13 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
} }
return b; return b;
}), }),
zones: (prev.zones ?? []).map((z) => zones: (prev.zones ?? []).map((z) => {
z.unlockBossId === bossId ? { ...z, status: "unlocked" as const } : z, if (z.status !== "locked" || z.unlockBossId !== bossId) return z;
), const questOk =
z.unlockQuestId == null ||
prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed");
return questOk ? { ...z, status: "unlocked" as const } : z;
}),
resources: result.rewards resources: result.rewards
? { ? {
...prev.resources, ...prev.resources,
+42 -1
View File
@@ -136,18 +136,57 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
return { ...quest, status: "completed" as const }; return { ...quest, status: "completed" as const };
}); });
// Unlock quests whose prerequisites are now all completed // Unlock quests whose prerequisites are now all completed and whose zone is unlocked
const completedIds = new Set( const completedIds = new Set(
updatedQuests.filter((q) => q.status === "completed").map((q) => q.id), updatedQuests.filter((q) => q.status === "completed").map((q) => q.id),
); );
const fullyUpdatedQuests = updatedQuests.map((quest) => { const fullyUpdatedQuests = updatedQuests.map((quest) => {
if (quest.status !== "locked") return quest; if (quest.status !== "locked") return quest;
const zone = state.zones.find((z) => z.id === quest.zoneId);
if (zone?.status === "locked") return quest;
if (quest.prerequisiteIds.every((id) => completedIds.has(id))) { if (quest.prerequisiteIds.every((id) => completedIds.has(id))) {
return { ...quest, status: "available" as const }; return { ...quest, status: "available" as const };
} }
return quest; return quest;
}); });
// Unlock zones whose both conditions are now satisfied after quest completion:
// (1) the gate boss has been defeated, (2) the gate quest is now completed
const updatedZones = state.zones.map((zone) => {
if (zone.status === "unlocked") return zone;
const bossOk =
zone.unlockBossId == null ||
state.bosses.some((b) => b.id === zone.unlockBossId && b.status === "defeated");
const questOk =
zone.unlockQuestId == null || completedIds.has(zone.unlockQuestId);
if (bossOk && questOk) {
return { ...zone, status: "unlocked" as const };
}
return zone;
});
// Activate the first boss in any zone that just became unlocked this tick
const newlyUnlockedZoneIds = new Set(
updatedZones
.filter((z) => {
const wasLocked = state.zones.find((oz) => oz.id === z.id)?.status === "locked";
return z.status === "unlocked" && wasLocked;
})
.map((z) => z.id),
);
let updatedBosses = state.bosses;
if (newlyUnlockedZoneIds.size > 0) {
updatedBosses = state.bosses.map((boss) => {
if (!newlyUnlockedZoneIds.has(boss.zoneId ?? "")) return boss;
const zoneBosses = state.bosses.filter((b) => b.zoneId === boss.zoneId);
const firstBoss = zoneBosses[0];
if (firstBoss?.id === boss.id && boss.status === "locked") {
return { ...boss, status: "available" as const };
}
return boss;
});
}
const newGold = state.resources.gold + goldGained + questGold; const newGold = state.resources.gold + goldGained + questGold;
const newEssence = state.resources.essence + essenceGained + questEssence; const newEssence = state.resources.essence + essenceGained + questEssence;
const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold; const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold;
@@ -168,6 +207,8 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
upgrades: updatedUpgrades, upgrades: updatedUpgrades,
adventurers: updatedAdventurers, adventurers: updatedAdventurers,
equipment: updatedEquipment, equipment: updatedEquipment,
bosses: updatedBosses,
zones: updatedZones,
lastTickAt: now, lastTickAt: now,
}; };
+3 -1
View File
@@ -6,6 +6,8 @@ export interface Zone {
description: string; description: string;
emoji: string; emoji: string;
status: ZoneStatus; status: ZoneStatus;
/** Boss ID whose defeat unlocks this zone (null for the starter zone) */ /** Boss ID whose defeat is required to unlock this zone (null for the starter zone) */
unlockBossId: string | null; unlockBossId: string | null;
/** Quest ID that must be completed to unlock this zone (null for the starter zone) */
unlockQuestId: string | null;
} }