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
+14 -8
View File
@@ -155,15 +155,21 @@ bossRouter.post("/challenge", async (context) => {
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 ?? [])) {
if (zone.unlockBossId === body.bossId) {
zone.status = "unlocked";
const newZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id);
const firstNewBoss = newZoneBosses[0];
if (firstNewBoss && firstNewBoss.prestigeRequirement <= state.prestige.count) {
firstNewBoss.status = "available";
}
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";
const newZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id);
const firstNewBoss = newZoneBosses[0];
if (firstNewBoss && firstNewBoss.prestigeRequirement <= state.prestige.count) {
firstNewBoss.status = "available";
}
}
+55 -8
View File
@@ -98,6 +98,14 @@ gameRouter.get("/load", async (context) => {
quest.rewards = structuredClone(defaults.rewards);
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
if (quest.status === "completed") {
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
if (!Array.isArray(state.zones) || state.zones.length === 0) {
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) {
if (zone.unlockBossId != null) {
const unlockBoss = state.bosses.find((b) => b.id === zone.unlockBossId);
if (unlockBoss?.status === "defeated") {
if (zone.unlockBossId != null || zone.unlockQuestId != null) {
if (zoneUnlockConditionsMet(zone)) {
zone.status = "unlocked";
}
}
@@ -153,10 +170,9 @@ gameRouter.get("/load", async (context) => {
for (const defaultZone of DEFAULT_ZONES) {
if (!state.zones.some((z) => z.id === defaultZone.id)) {
const newZone = structuredClone(defaultZone);
// Infer unlock state from defeated bosses
if (newZone.unlockBossId != null) {
const unlockBoss = state.bosses.find((b) => b.id === newZone.unlockBossId);
if (unlockBoss?.status === "defeated") {
// Infer unlock state from current boss + quest completion
if (newZone.unlockBossId != null || newZone.unlockQuestId != null) {
if (zoneUnlockConditionsMet(newZone)) {
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
for (const boss of state.bosses) {
const defaults = DEFAULT_BOSSES.find((d) => d.id === boss.id);