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
+42 -1
View File
@@ -136,18 +136,57 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
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(
updatedQuests.filter((q) => q.status === "completed").map((q) => q.id),
);
const fullyUpdatedQuests = updatedQuests.map((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))) {
return { ...quest, status: "available" as const };
}
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 newEssence = state.resources.essence + essenceGained + questEssence;
const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold;
@@ -168,6 +207,8 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
upgrades: updatedUpgrades,
adventurers: updatedAdventurers,
equipment: updatedEquipment,
bosses: updatedBosses,
zones: updatedZones,
lastTickAt: now,
};