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,302 @@
|
||||
/**
|
||||
* @file API client for communicating with the Elysium backend.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import type {
|
||||
AboutResponse,
|
||||
ApotheosisRequest,
|
||||
ApotheosisResponse,
|
||||
AuthResponse,
|
||||
BossChallengeRequest,
|
||||
BossChallengeResponse,
|
||||
BuyEchoUpgradeRequest,
|
||||
BuyEchoUpgradeResponse,
|
||||
BuyPrestigeUpgradeRequest,
|
||||
BuyPrestigeUpgradeResponse,
|
||||
CraftRecipeRequest,
|
||||
CraftRecipeResponse,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
ExploreStartRequest,
|
||||
ExploreStartResponse,
|
||||
LoadResponse,
|
||||
PrestigeRequest,
|
||||
PrestigeResponse,
|
||||
PublicProfileResponse,
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
TranscendenceRequest,
|
||||
TranscendenceResponse,
|
||||
UpdateProfileRequest,
|
||||
UpdateProfileResponse,
|
||||
} from "@elysium/types";
|
||||
|
||||
const baseUrl = "/api";
|
||||
|
||||
const getToken = (): string | null => {
|
||||
return globalThis.localStorage.getItem("elysium_token");
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- HTTP header names require specific casing */
|
||||
const buildHeaders = (): Record<string, string> => {
|
||||
const token = getToken();
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...token !== null && token.length > 0
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {},
|
||||
};
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention -- HTTP header names require specific casing */
|
||||
|
||||
const fetchJson = async <T>(
|
||||
path: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> => {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: { ...buildHeaders(), ...options?.headers },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- JSON error response requires type assertion */
|
||||
const errorBody = (await response.json().catch(() => {
|
||||
return { error: "Unknown error" };
|
||||
})) as Record<string, unknown>;
|
||||
const message
|
||||
= typeof errorBody.error === "string"
|
||||
? errorBody.error
|
||||
: "Unknown error";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- JSON response requires type assertion */
|
||||
return await (response.json() as Promise<T>);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the about information from the API.
|
||||
* @returns The about response data.
|
||||
*/
|
||||
const getAbout = async(): Promise<AboutResponse> => {
|
||||
return await fetchJson<AboutResponse>("/about");
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the Discord OAuth URL from the API.
|
||||
* @returns The authentication URL string.
|
||||
*/
|
||||
const getAuthUrl = async(): Promise<string> => {
|
||||
const data = await fetchJson<{ url: string }>("/auth/url");
|
||||
return data.url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the Discord OAuth callback and stores the auth token.
|
||||
* @param code - The OAuth authorization code from Discord.
|
||||
* @returns The authentication response data.
|
||||
*/
|
||||
const handleAuthCallback = async(code: string): Promise<AuthResponse> => {
|
||||
const data = await fetchJson<AuthResponse>(`/auth/callback?code=${code}`);
|
||||
globalThis.localStorage.setItem("elysium_token", data.token);
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads the current game state from the server.
|
||||
* @returns The load response containing the game state.
|
||||
*/
|
||||
const loadGame = async(): Promise<LoadResponse> => {
|
||||
return await fetchJson<LoadResponse>("/game/load");
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets all game progress on the server.
|
||||
* @returns The load response after reset.
|
||||
*/
|
||||
const resetProgress = async(): Promise<LoadResponse> => {
|
||||
return await fetchJson<LoadResponse>("/game/reset", { method: "POST" });
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves the current game state to the server.
|
||||
* @param body - The save request payload containing the game state.
|
||||
* @returns The save response data.
|
||||
*/
|
||||
const saveGame = async(body: SaveRequest): Promise<SaveResponse> => {
|
||||
return await fetchJson<SaveResponse>("/game/save", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Challenges a boss with the current game state.
|
||||
* @param body - The boss challenge request payload.
|
||||
* @returns The boss challenge response data.
|
||||
*/
|
||||
const challengeBoss = async(
|
||||
body: BossChallengeRequest,
|
||||
): Promise<BossChallengeResponse> => {
|
||||
return await fetchJson<BossChallengeResponse>("/boss/challenge", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers a prestige reset on the server.
|
||||
* @param body - The prestige request payload.
|
||||
* @returns The prestige response data.
|
||||
*/
|
||||
const prestige = async(body: PrestigeRequest): Promise<PrestigeResponse> => {
|
||||
return await fetchJson<PrestigeResponse>("/prestige", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Purchases a prestige upgrade on the server.
|
||||
* @param body - The buy prestige upgrade request payload.
|
||||
* @returns The buy prestige upgrade response data.
|
||||
*/
|
||||
const buyPrestigeUpgrade = async(
|
||||
body: BuyPrestigeUpgradeRequest,
|
||||
): Promise<BuyPrestigeUpgradeResponse> => {
|
||||
return await fetchJson<BuyPrestigeUpgradeResponse>("/prestige/buy-upgrade", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers a transcendence reset on the server.
|
||||
* @param body - The transcendence request payload.
|
||||
* @returns The transcendence response data.
|
||||
*/
|
||||
const transcend = async(
|
||||
body: TranscendenceRequest,
|
||||
): Promise<TranscendenceResponse> => {
|
||||
return await fetchJson<TranscendenceResponse>("/transcendence", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Purchases an echo upgrade on the server.
|
||||
* @param body - The buy echo upgrade request payload.
|
||||
* @returns The buy echo upgrade response data.
|
||||
*/
|
||||
const buyEchoUpgrade = async(
|
||||
body: BuyEchoUpgradeRequest,
|
||||
): Promise<BuyEchoUpgradeResponse> => {
|
||||
return await fetchJson<BuyEchoUpgradeResponse>("/transcendence/buy-upgrade", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers an apotheosis reset on the server.
|
||||
* @param body - The apotheosis request payload.
|
||||
* @returns The apotheosis response data.
|
||||
*/
|
||||
const achieveApotheosis = async(
|
||||
body: ApotheosisRequest,
|
||||
): Promise<ApotheosisResponse> => {
|
||||
return await fetchJson<ApotheosisResponse>("/apotheosis", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts an exploration in a given area.
|
||||
* @param body - The exploration start request payload.
|
||||
* @returns The exploration start response data.
|
||||
*/
|
||||
const startExploration = async(
|
||||
body: ExploreStartRequest,
|
||||
): Promise<ExploreStartResponse> => {
|
||||
return await fetchJson<ExploreStartResponse>("/explore/start", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects the rewards from a completed exploration.
|
||||
* @param body - The exploration collect request payload.
|
||||
* @returns The exploration collect response data.
|
||||
*/
|
||||
const collectExploration = async(
|
||||
body: ExploreCollectRequest,
|
||||
): Promise<ExploreCollectResponse> => {
|
||||
return await fetchJson<ExploreCollectResponse>("/explore/collect", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Crafts a recipe on the server.
|
||||
* @param body - The craft recipe request payload.
|
||||
* @returns The craft recipe response data.
|
||||
*/
|
||||
const craftRecipe = async(
|
||||
body: CraftRecipeRequest,
|
||||
): Promise<CraftRecipeResponse> => {
|
||||
return await fetchJson<CraftRecipeResponse>("/craft", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches a public player profile by Discord ID.
|
||||
* @param discordId - The Discord ID of the player to look up.
|
||||
* @returns The public profile response data.
|
||||
*/
|
||||
const getPublicProfile = async(
|
||||
discordId: string,
|
||||
): Promise<PublicProfileResponse> => {
|
||||
return await fetchJson<PublicProfileResponse>(`/profile/${discordId}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the current player's profile.
|
||||
* @param body - The update profile request payload.
|
||||
* @returns The update profile response data.
|
||||
*/
|
||||
const updateProfile = async(
|
||||
body: UpdateProfileRequest,
|
||||
): Promise<UpdateProfileResponse> => {
|
||||
return await fetchJson<UpdateProfileResponse>("/profile", {
|
||||
body: JSON.stringify(body),
|
||||
method: "PUT",
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
achieveApotheosis,
|
||||
buyEchoUpgrade,
|
||||
buyPrestigeUpgrade,
|
||||
challengeBoss,
|
||||
collectExploration,
|
||||
craftRecipe,
|
||||
getAbout,
|
||||
getAuthUrl,
|
||||
getPublicProfile,
|
||||
handleAuthCallback,
|
||||
loadGame,
|
||||
prestige,
|
||||
resetProgress,
|
||||
saveGame,
|
||||
startExploration,
|
||||
transcend,
|
||||
updateProfile,
|
||||
};
|
||||
Reference in New Issue
Block a user