diff --git a/apps/api/src/data/zones.ts b/apps/api/src/data/zones.ts index 1d0c745..0beadf3 100644 --- a/apps/api/src/data/zones.ts +++ b/apps/api/src/data/zones.ts @@ -9,6 +9,7 @@ export const DEFAULT_ZONES: Zone[] = [ emoji: "🌿", status: "unlocked", unlockBossId: null, + unlockQuestId: null, }, { 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.", emoji: "🏛️", status: "locked", - unlockBossId: "lich_queen", - }, - { - 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", + unlockBossId: "forest_giant", + unlockQuestId: "ancient_ruins", }, { id: "frozen_peaks", @@ -36,6 +29,17 @@ export const DEFAULT_ZONES: Zone[] = [ emoji: "❄️", status: "locked", 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", @@ -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.", emoji: "🌋", status: "locked", - unlockBossId: "bone_colossus", + unlockBossId: "mud_kraken", + unlockQuestId: "plague_ruins", }, { 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.", emoji: "🌌", status: "locked", - unlockBossId: "void_titan", + unlockBossId: "phoenix_lord", + unlockQuestId: "the_forge", }, ]; diff --git a/apps/api/src/routes/boss.ts b/apps/api/src/routes/boss.ts index c85b985..e7c9105 100644 --- a/apps/api/src/routes/boss.ts +++ b/apps/api/src/routes/boss.ts @@ -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"; } } diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index 7d0282e..257e261 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -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); diff --git a/apps/web/src/components/game/BossPanel.tsx b/apps/web/src/components/game/BossPanel.tsx index 0c017b5..d423318 100644 --- a/apps/web/src/components/game/BossPanel.tsx +++ b/apps/web/src/components/game/BossPanel.tsx @@ -157,12 +157,20 @@ export const BossPanel = (): React.JSX.Element => { for (let i = 0; i < allZoneBosses.length; i++) { const boss = allZoneBosses[i]; if (!boss || boss.status !== "locked") continue; - if (i === 0 && zone.unlockBossId) { - const gateBoss = state.bosses.find((b) => b.id === zone.unlockBossId); - if (gateBoss) { - bossUnlockHints.set(boss.id, `⚔️ Defeat: ${gateBoss.name}`); + if (i === 0) { + const parts: string[] = []; + if (zone.unlockBossId) { + const gateBoss = state.bosses.find((b) => b.id === zone.unlockBossId); + if (gateBoss) parts.push(`⚔️ 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]; if (prevBoss) { bossUnlockHints.set(boss.id, `⚔️ Defeat: ${prevBoss.name} first`); diff --git a/apps/web/src/context/GameContext.tsx b/apps/web/src/context/GameContext.tsx index 492ac9e..4b7155d 100644 --- a/apps/web/src/context/GameContext.tsx +++ b/apps/web/src/context/GameContext.tsx @@ -279,7 +279,14 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React const nextZoneBossId = zoneBosses[zoneIdx + 1]?.id; // 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 firstBoss = prev.bosses.find((b) => b.zoneId === z.id); return firstBoss?.id; @@ -297,9 +304,13 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React } return b; }), - zones: (prev.zones ?? []).map((z) => - z.unlockBossId === bossId ? { ...z, status: "unlocked" as const } : z, - ), + zones: (prev.zones ?? []).map((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 ? { ...prev.resources, diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index 3ba619b..0a6ecbb 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -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, }; diff --git a/packages/types/src/interfaces/Zone.ts b/packages/types/src/interfaces/Zone.ts index f93b5d2..789b467 100644 --- a/packages/types/src/interfaces/Zone.ts +++ b/packages/types/src/interfaces/Zone.ts @@ -6,6 +6,8 @@ export interface Zone { description: string; emoji: string; 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; + /** Quest ID that must be completed to unlock this zone (null for the starter zone) */ + unlockQuestId: string | null; }