generated from nhcarrigan/template
feat: initial prototype — core game systems (#30)
## Summary This PR represents the full v1 prototype, implementing the core game systems for Elysium. - Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests - Adventurer hiring with batch size selector and progressive tier cost scaling - Prestige, transcendence, and apotheosis systems with auto-prestige support - Character sheet, titles, leaderboards, companion system, and daily login bonuses - Auto-quest and auto-boss toggles - Discord webhook notifications on prestige/transcendence/apotheosis - Discord role awarded on apotheosis - Responsive design and overarching story/lore system - In-game sound effects and browser notifications for key events - Support link button in the resource bar - Full test coverage (100% on `apps/api` and `packages/types`) - CI pipeline: lint → build → test ## Closes Closes #1 Closes #2 Closes #3 Closes #4 Closes #5 Closes #6 Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Closes #13 Closes #14 Closes #16 Closes #19 Closes #20 Closes #21 Closes #22 Closes #23 Closes #24 Closes #25 Closes #26 Closes #27 Closes #29 ✨ This issue was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #30 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* @file Game tick engine for Elysium.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- Snake_case IDs and SCREAMING_SNAKE are conventional for game data */
|
||||
/* eslint-disable complexity -- Tick engine is inherently complex with many game systems */
|
||||
/* eslint-disable max-lines-per-function -- Tick engine processes many systems in one pass for performance */
|
||||
/* eslint-disable max-statements -- Tick engine requires many state variables across all game systems */
|
||||
/* eslint-disable max-lines -- Engine file necessarily exceeds line limit */
|
||||
/* eslint-disable import/group-exports -- Exports appear alongside their definitions for readability */
|
||||
/* eslint-disable import/exports-last -- Exports appear alongside their definitions for readability */
|
||||
/* eslint-disable unicorn/no-array-reduce -- reduce is the most readable approach for multiplier chains */
|
||||
/* eslint-disable max-nested-callbacks -- Tick engine requires nested array operations for game logic */
|
||||
import {
|
||||
type Achievement,
|
||||
type Equipment,
|
||||
type GameState,
|
||||
computeSetBonuses,
|
||||
getActiveCompanionBonus,
|
||||
} from "@elysium/types";
|
||||
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||
|
||||
/**
|
||||
* Checks all achievements against the current game state and returns an updated
|
||||
* achievements array, marking newly-met conditions with the current timestamp.
|
||||
* @param state - The current game state to check achievements against.
|
||||
* @returns Updated achievements array with newly unlocked achievements timestamped.
|
||||
*/
|
||||
const checkAchievements = (state: GameState): Array<Achievement> => {
|
||||
const now = Date.now();
|
||||
return state.achievements.map((achievement) => {
|
||||
if (achievement.unlockedAt !== null) {
|
||||
return achievement;
|
||||
}
|
||||
|
||||
const { condition } = achievement;
|
||||
let met = false;
|
||||
|
||||
switch (condition.type) {
|
||||
case "totalGoldEarned":
|
||||
met = state.player.totalGoldEarned >= condition.amount;
|
||||
break;
|
||||
case "totalClicks":
|
||||
met = state.player.totalClicks >= condition.amount;
|
||||
break;
|
||||
case "bossesDefeated":
|
||||
met
|
||||
= state.bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length >= condition.amount;
|
||||
break;
|
||||
case "questsCompleted":
|
||||
met
|
||||
= state.quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).length >= condition.amount;
|
||||
break;
|
||||
case "adventurerTotal":
|
||||
met
|
||||
= state.adventurers.reduce((sum, adventurer) => {
|
||||
return sum + adventurer.count;
|
||||
}, 0) >= condition.amount;
|
||||
break;
|
||||
case "prestigeCount":
|
||||
met = state.prestige.count >= condition.amount;
|
||||
break;
|
||||
case "equipmentOwned":
|
||||
met
|
||||
= state.equipment.filter((item) => {
|
||||
return item.owned;
|
||||
}).length >= condition.amount;
|
||||
break;
|
||||
default:
|
||||
/* V8 ignore next -- @preserve */ break;
|
||||
}
|
||||
|
||||
return met
|
||||
? { ...achievement, unlockedAt: now }
|
||||
: achievement;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Maximum value any resource can accumulate to. Beyond this JS floats lose all useful precision.
|
||||
*/
|
||||
export const RESOURCE_CAP = 1e300;
|
||||
|
||||
/**
|
||||
* Probability of quest failure per zone — scales from 10% (early game) to 40% (end game).
|
||||
* On failure the quest resets to "available" with no rewards; the player must wait the
|
||||
* full duration again on their next attempt.
|
||||
*/
|
||||
const zoneFailureChance: Record<string, number> = {
|
||||
abyssal_trench: 0.24,
|
||||
astral_void: 0.2,
|
||||
celestial_reaches: 0.22,
|
||||
cosmic_maelstrom: 0.4,
|
||||
crystalline_spire: 0.28,
|
||||
eternal_throne: 0.32,
|
||||
frozen_peaks: 0.14,
|
||||
infernal_court: 0.26,
|
||||
infinite_expanse: 0.36,
|
||||
primeval_sanctum: 0.4,
|
||||
primordial_chaos: 0.34,
|
||||
reality_forge: 0.38,
|
||||
shadow_marshes: 0.16,
|
||||
shattered_ruins: 0.12,
|
||||
the_absolute: 0.4,
|
||||
verdant_vale: 0.1,
|
||||
void_sanctum: 0.3,
|
||||
volcanic_depths: 0.18,
|
||||
};
|
||||
|
||||
/**
|
||||
* Caps a resource value at RESOURCE_CAP.
|
||||
* @param value - The resource value to cap.
|
||||
* @returns The capped value.
|
||||
*/
|
||||
const capResource = (value: number): number => {
|
||||
return Math.min(value, RESOURCE_CAP);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure function — applies one game tick to the state.
|
||||
* DeltaSeconds: time elapsed since last tick.
|
||||
* Returns a new GameState (does not mutate the original).
|
||||
* @param state - The current game state.
|
||||
* @param deltaSeconds - Time elapsed since last tick in seconds.
|
||||
* @returns A new GameState with the tick applied.
|
||||
*/
|
||||
export const applyTick = (
|
||||
state: GameState,
|
||||
deltaSeconds: number,
|
||||
): GameState => {
|
||||
const equippedItems: Array<Equipment> = state.equipment.filter((item) => {
|
||||
return item.equipped;
|
||||
});
|
||||
const equipmentGoldMultiplier = equippedItems.reduce((mult, item) => {
|
||||
return mult * (item.bonus.goldMultiplier ?? 1);
|
||||
}, 1);
|
||||
const setGoldMultiplier = computeSetBonuses(
|
||||
equippedItems.map((item) => {
|
||||
return item.id;
|
||||
}),
|
||||
EQUIPMENT_SETS,
|
||||
).goldMultiplier;
|
||||
|
||||
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
|
||||
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
|
||||
const runestonesCrystal = state.prestige.runestonesCrystalMultiplier ?? 1;
|
||||
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
||||
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
|
||||
const craftedEssenceMultiplier
|
||||
= state.exploration?.craftedEssenceMultiplier ?? 1;
|
||||
|
||||
const companionBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionGoldMult
|
||||
= companionBonus?.type === "passiveGold"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
const companionEssenceMult
|
||||
= companionBonus?.type === "essenceIncome"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
const companionQuestTimeReduction
|
||||
= companionBonus?.type === "questTime"
|
||||
? companionBonus.value
|
||||
: 0;
|
||||
|
||||
let goldGained = 0;
|
||||
let essenceGained = 0;
|
||||
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (!adventurer.unlocked || adventurer.count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const upgradeMultiplier = state.upgrades.
|
||||
filter((upgrade) => {
|
||||
const isGlobal = upgrade.target === "global";
|
||||
const isThisAdventurer
|
||||
= upgrade.target === "adventurer"
|
||||
&& upgrade.adventurerId === adventurer.id;
|
||||
return upgrade.purchased && (isGlobal || isThisAdventurer);
|
||||
}).
|
||||
reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
|
||||
const prestige = state.prestige.productionMultiplier;
|
||||
|
||||
const goldPerTick
|
||||
= adventurer.goldPerSecond
|
||||
* adventurer.count
|
||||
* upgradeMultiplier
|
||||
* prestige
|
||||
* runestonesIncome
|
||||
* echoIncome
|
||||
* equipmentGoldMultiplier
|
||||
* setGoldMultiplier
|
||||
* craftedGoldMultiplier
|
||||
* companionGoldMult
|
||||
* deltaSeconds;
|
||||
goldGained = goldGained + goldPerTick;
|
||||
|
||||
const essencePerTick
|
||||
= adventurer.essencePerSecond
|
||||
* adventurer.count
|
||||
* upgradeMultiplier
|
||||
* prestige
|
||||
* runestonesEssence
|
||||
* craftedEssenceMultiplier
|
||||
* companionEssenceMult
|
||||
* deltaSeconds;
|
||||
essenceGained = essenceGained + essencePerTick;
|
||||
}
|
||||
|
||||
// Complete active quests and apply their rewards
|
||||
const now = Date.now();
|
||||
let questGold = 0;
|
||||
let questEssence = 0;
|
||||
let questCrystals = 0;
|
||||
|
||||
let updatedUpgrades = state.upgrades;
|
||||
let updatedAdventurers = state.adventurers;
|
||||
let updatedEquipmentReference = state.equipment;
|
||||
|
||||
const updatedQuests = state.quests.map((quest) => {
|
||||
const effectiveQuestMs
|
||||
= quest.durationSeconds * (1 - companionQuestTimeReduction) * 1000;
|
||||
if (
|
||||
quest.status !== "active"
|
||||
|| quest.startedAt === undefined
|
||||
|| now < quest.startedAt + effectiveQuestMs
|
||||
) {
|
||||
return quest;
|
||||
}
|
||||
|
||||
const failureChance = zoneFailureChance[quest.zoneId] ?? 0.2;
|
||||
if (Math.random() < failureChance) {
|
||||
const { startedAt: _dropped, ...questWithoutStartedAt } = quest;
|
||||
return {
|
||||
...questWithoutStartedAt,
|
||||
lastFailedAt: now,
|
||||
status: "available" as const,
|
||||
};
|
||||
}
|
||||
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "gold" && reward.amount !== undefined) {
|
||||
questGold = questGold + reward.amount;
|
||||
} else if (reward.type === "essence" && reward.amount !== undefined) {
|
||||
questEssence = questEssence + reward.amount;
|
||||
} else if (reward.type === "crystals" && reward.amount !== undefined) {
|
||||
const crystalAmount = reward.amount;
|
||||
const crystalGain = crystalAmount * runestonesCrystal;
|
||||
questCrystals = questCrystals + crystalGain;
|
||||
} else if (reward.type === "upgrade" && reward.targetId !== undefined) {
|
||||
updatedUpgrades = updatedUpgrades.map((upgrade) => {
|
||||
return upgrade.id === reward.targetId
|
||||
? { ...upgrade, unlocked: true }
|
||||
: upgrade;
|
||||
});
|
||||
} else if (
|
||||
reward.type === "adventurer"
|
||||
&& reward.targetId !== undefined
|
||||
) {
|
||||
updatedAdventurers = updatedAdventurers.map((adventurer) => {
|
||||
return adventurer.id === reward.targetId
|
||||
? { ...adventurer, unlocked: true }
|
||||
: adventurer;
|
||||
});
|
||||
} else if (reward.type === "equipment" && reward.targetId !== undefined) {
|
||||
const rewardTargetId = reward.targetId;
|
||||
const currentEquipment = updatedEquipmentReference;
|
||||
updatedEquipmentReference = currentEquipment.map((item) => {
|
||||
if (item.id !== rewardTargetId) {
|
||||
return item;
|
||||
}
|
||||
const slotEmpty = !currentEquipment.some((other) => {
|
||||
return other.type === item.type && other.equipped;
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
equipped: slotEmpty || item.equipped,
|
||||
owned: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ...quest, status: "completed" as const };
|
||||
});
|
||||
|
||||
// Unlock quests whose prerequisites are now all completed and whose zone is unlocked
|
||||
const completedIds = new Set(
|
||||
updatedQuests.
|
||||
filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).
|
||||
map((quest) => {
|
||||
return quest.id;
|
||||
}),
|
||||
);
|
||||
const fullyUpdatedQuests = updatedQuests.map((quest) => {
|
||||
if (quest.status !== "locked") {
|
||||
return quest;
|
||||
}
|
||||
const questZone = state.zones.find((searchZone) => {
|
||||
return searchZone.id === quest.zoneId;
|
||||
});
|
||||
if (questZone?.status === "locked") {
|
||||
return quest;
|
||||
}
|
||||
if (
|
||||
quest.prerequisiteIds.every((id) => {
|
||||
return 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((boss) => {
|
||||
return boss.id === zone.unlockBossId && boss.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((zone) => {
|
||||
const wasLocked
|
||||
= state.zones.find((originalZone) => {
|
||||
return originalZone.id === zone.id;
|
||||
})?.status === "locked";
|
||||
return zone.status === "unlocked" && wasLocked;
|
||||
}).
|
||||
map((zone) => {
|
||||
return zone.id;
|
||||
}),
|
||||
);
|
||||
let updatedBosses = state.bosses;
|
||||
if (newlyUnlockedZoneIds.size > 0) {
|
||||
updatedBosses = state.bosses.map((boss) => {
|
||||
if (newlyUnlockedZoneIds.has(boss.zoneId)) {
|
||||
const zoneBosses = state.bosses.filter((zoneBoss) => {
|
||||
return zoneBoss.zoneId === boss.zoneId;
|
||||
});
|
||||
const [ firstBoss ] = zoneBosses;
|
||||
if (firstBoss?.id === boss.id && boss.status === "locked") {
|
||||
return { ...boss, status: "available" as const };
|
||||
}
|
||||
}
|
||||
return boss;
|
||||
});
|
||||
}
|
||||
|
||||
// Count quests newly completed this tick and update daily challenge progress
|
||||
const newlyCompletedQuestCount = updatedQuests.filter((quest, index) => {
|
||||
const wasNotCompleted = state.quests[index]?.status !== "completed";
|
||||
return quest.status === "completed" && wasNotCompleted;
|
||||
}).length;
|
||||
|
||||
let updatedDailyChallenges = state.dailyChallenges;
|
||||
let challengeCrystals = 0;
|
||||
if (updatedDailyChallenges !== undefined && newlyCompletedQuestCount > 0) {
|
||||
const result = updateChallengeProgress(
|
||||
updatedDailyChallenges,
|
||||
"questsCompleted",
|
||||
newlyCompletedQuestCount,
|
||||
);
|
||||
updatedDailyChallenges = result.updatedChallenges;
|
||||
challengeCrystals = result.crystalsAwarded;
|
||||
}
|
||||
|
||||
const goldValue = capResource(state.resources.gold + goldGained + questGold);
|
||||
const essenceValue = capResource(
|
||||
state.resources.essence + essenceGained + questEssence,
|
||||
);
|
||||
const totalGoldEarnedValue
|
||||
= state.player.totalGoldEarned + goldGained + questGold;
|
||||
|
||||
const partialState: GameState = {
|
||||
...state,
|
||||
resources: {
|
||||
...state.resources,
|
||||
crystals: capResource(
|
||||
state.resources.crystals + questCrystals + challengeCrystals,
|
||||
),
|
||||
essence: essenceValue,
|
||||
gold: goldValue,
|
||||
},
|
||||
...updatedDailyChallenges === undefined
|
||||
? {}
|
||||
: { dailyChallenges: updatedDailyChallenges },
|
||||
adventurers: updatedAdventurers,
|
||||
bosses: updatedBosses,
|
||||
equipment: updatedEquipmentReference,
|
||||
lastTickAt: now,
|
||||
player: {
|
||||
...state.player,
|
||||
totalGoldEarned: totalGoldEarnedValue,
|
||||
},
|
||||
quests: fullyUpdatedQuests,
|
||||
upgrades: updatedUpgrades,
|
||||
zones: updatedZones,
|
||||
};
|
||||
|
||||
// Check achievements and apply crystal rewards for newly unlocked ones
|
||||
const updatedAchievements = checkAchievements(partialState);
|
||||
const crystalsFromAchievements = updatedAchievements.reduce(
|
||||
(sum, achievement, index) => {
|
||||
const wasLocked = state.achievements[index]?.unlockedAt === null;
|
||||
const isNowUnlocked = achievement.unlockedAt !== null;
|
||||
if (wasLocked && isNowUnlocked) {
|
||||
return sum + (achievement.reward?.crystals ?? 0);
|
||||
}
|
||||
return sum;
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
...partialState,
|
||||
achievements: updatedAchievements,
|
||||
resources: {
|
||||
...partialState.resources,
|
||||
crystals: capResource(
|
||||
partialState.resources.crystals + crystalsFromAchievements,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the effective click power, including upgrades and equipped trinkets.
|
||||
* @param state - The current game state.
|
||||
* @returns The calculated click power value.
|
||||
*/
|
||||
export const calculateClickPower = (state: GameState): number => {
|
||||
const clickMultiplier = state.upgrades.
|
||||
filter((upgrade) => {
|
||||
return upgrade.purchased && upgrade.target === "click";
|
||||
}).
|
||||
reduce((mult, upgrade) => {
|
||||
return mult * upgrade.multiplier;
|
||||
}, 1);
|
||||
|
||||
const equippedItems = state.equipment.filter((item) => {
|
||||
return item.equipped;
|
||||
});
|
||||
const equipmentClickMultiplier = equippedItems.
|
||||
filter((item) => {
|
||||
return item.bonus.clickMultiplier !== undefined;
|
||||
}).
|
||||
reduce((mult, item) => {
|
||||
return mult * (item.bonus.clickMultiplier ?? 1);
|
||||
}, 1);
|
||||
const setClickMultiplier = computeSetBonuses(
|
||||
equippedItems.map((item) => {
|
||||
return item.id;
|
||||
}),
|
||||
EQUIPMENT_SETS,
|
||||
).clickMultiplier;
|
||||
|
||||
const runestonesClick = state.prestige.runestonesClickMultiplier ?? 1;
|
||||
const echoIncome = state.transcendence?.echoIncomeMultiplier ?? 1;
|
||||
const craftedClickMultiplier = state.exploration?.craftedClickMultiplier ?? 1;
|
||||
|
||||
const companionClickBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionClickMult
|
||||
= companionClickBonus?.type === "clickGold"
|
||||
? 1 + companionClickBonus.value
|
||||
: 1;
|
||||
|
||||
return (
|
||||
state.baseClickPower
|
||||
* clickMultiplier
|
||||
* state.prestige.productionMultiplier
|
||||
* runestonesClick
|
||||
* echoIncome
|
||||
* equipmentClickMultiplier
|
||||
* setClickMultiplier
|
||||
* craftedClickMultiplier
|
||||
* companionClickMult
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user