generated from nhcarrigan/template
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:
@@ -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`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user