feat: v1 prototype — core game systems #30

Merged
naomi merged 84 commits from feat/prototype into main 2026-03-08 15:53:39 -07:00
9 changed files with 409 additions and 2 deletions
Showing only changes of commit 49bfc6a109 - Show all commits
+4
View File
@@ -44,6 +44,8 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
? (parsedNumberFormat as ProfileSettings["numberFormat"])
: "suffix";
return {
enableNotifications: rawObject.enableNotifications === true,
enableSounds: rawObject.enableSounds === true,
numberFormat: numberFormat,
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
showAdventurersRecruited: rawObject.showAdventurersRecruited !== false,
@@ -203,6 +205,8 @@ profileRouter.put("/", authMiddleware, async(context) => {
? (parsedNumberFormat as ProfileSettings["numberFormat"])
: "suffix";
const profileSettings: ProfileSettings = {
enableNotifications: body.profileSettings.enableNotifications ?? false,
enableSounds: body.profileSettings.enableSounds ?? false,
numberFormat: numberFormat,
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
@@ -245,6 +245,17 @@ const howToPlay = [
+ " progress is permanent and survives all resets.",
title: "📖 Story",
},
{
body:
"Enable sound effects and browser notifications in your profile settings"
+ " (click your character name in the top bar). Sound effects play when"
+ " you defeat a boss, complete or fail a quest, unlock an achievement,"
+ " prestige, transcend, or achieve apotheosis. Browser notifications"
+ " alert you to the same events even when the game tab is in the"
+ " background. You will be prompted to grant notification permission"
+ " when you first enable them.",
title: "🔔 Sounds & Notifications",
},
];
const formatDate = (dateString: string): string => {
@@ -16,6 +16,9 @@ import {
import { type ChangeEvent, type JSX, useEffect, useState } from "react";
import { updateProfile } from "../../api/client.js";
import { useGame } from "../../context/gameContext.js";
import {
requestNotificationPermission,
} from "../../utils/notification.js";
interface EditProfileModalProperties {
readonly onClose: ()=> void;
@@ -96,6 +99,8 @@ const EditProfileModal = ({
state,
numberFormat: currentNumberFormat,
setNumberFormat,
setEnableSounds,
setEnableNotifications,
} = useGame();
const player = state?.player;
@@ -157,6 +162,8 @@ const EditProfileModal = ({
profileSettings,
});
setNumberFormat(profileSettings.numberFormat);
setEnableSounds(profileSettings.enableSounds);
setEnableNotifications(profileSettings.enableNotifications);
setSaved(true);
setTimeout(onClose, 900);
} catch (error_: unknown) {
@@ -194,6 +201,30 @@ const EditProfileModal = ({
setBio(event.target.value);
}
function handleSoundsToggle(): void {
toggleSetting("enableSounds");
}
async function handleNotificationsEnable(): Promise<void> {
if (profileSettings.enableNotifications) {
toggleSetting("enableNotifications");
return;
}
const granted = await requestNotificationPermission();
if (granted) {
toggleSetting("enableNotifications");
} else {
setError(
"Browser notification permission was denied."
+ " Please enable it in your browser settings.",
);
}
}
function handleNotificationsToggle(): void {
void handleNotificationsEnable();
}
const isSaveDisabled = saving || characterName.trim() === "";
let saveLabel = "Save Profile";
@@ -348,6 +379,46 @@ const EditProfileModal = ({
</button>
</div>
<div className="edit-profile-section">
<p className="edit-profile-label">{"Sounds & Notifications"}</p>
<p className="edit-profile-sublabel">
{"Control in-game sound effects and browser notifications."}
</p>
<button
className={`stat-toggle-btn ${
profileSettings.enableSounds
? "stat-toggle-on"
: "stat-toggle-off"
}`}
onClick={handleSoundsToggle}
type="button"
>
<span>{"🔊 Sound Effects"}</span>
<span className="stat-toggle-indicator">
{profileSettings.enableSounds
? "✓ On"
: "Off"}
</span>
</button>
<button
className={`stat-toggle-btn ${
profileSettings.enableNotifications
? "stat-toggle-on"
: "stat-toggle-off"
}`}
onClick={handleNotificationsToggle}
type="button"
>
<span>{"🔔 Browser Notifications"}</span>
<span className="stat-toggle-indicator">
{profileSettings.enableNotifications
? "✓ On"
: "Off"
}
</span>
</button>
</div>
<div className="edit-profile-section">
<p className="edit-profile-label">{"Number Format"}</p>
<p className="edit-profile-sublabel">
@@ -15,6 +15,8 @@ import {
PRESTIGE_UPGRADES,
PRESTIGE_UPGRADE_CATEGORY_LABELS,
} from "../../data/prestigeUpgrades.js";
import { sendNotification } from "../../utils/notification.js";
import { playSound } from "../../utils/sound.js";
import type { PrestigeUpgradeCategory } from "@elysium/types";
const baseThreshold = 1_000_000;
@@ -84,6 +86,8 @@ const PrestigePanel = (): JSX.Element => {
reload,
formatNumber,
buyPrestigeUpgrade,
enableNotifications,
enableSounds,
toggleAutoPrestige,
} = useGame();
const [ isPending, setIsPending ] = useState(false);
@@ -124,6 +128,15 @@ const PrestigePanel = (): JSX.Element => {
milestoneRunestones: data.milestoneRunestones,
runestones: data.runestones,
});
if (enableSounds) {
playSound("prestige");
}
if (enableNotifications) {
sendNotification(
"⭐ Prestige!",
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
);
}
await reload();
} catch (error_: unknown) {
setPrestigeError(
+140 -2
View File
@@ -58,6 +58,8 @@ import {
} from "../engine/tick.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { formatNumber as formatNumberUtil } from "../utils/format.js";
import { sendNotification } from "../utils/notification.js";
import { playSound } from "../utils/sound.js";
const autoSaveIntervalMs = 30_000;
const autoPrestigeThresholdBase = 1_000_000;
@@ -342,6 +344,26 @@ interface GameContextValue {
*/
setNumberFormat: (format: NumberFormat)=> void;
/**
* Whether in-game sound effects are enabled.
*/
enableSounds: boolean;
/**
* Whether browser system notifications are enabled.
*/
enableNotifications: boolean;
/**
* Update the enable-sounds preference.
*/
setEnableSounds: (enabled: boolean)=> void;
/**
* Update the enable-notifications preference.
*/
setEnableNotifications: (enabled: boolean)=> void;
/**
* Format a number using the player's chosen notation style.
*/
@@ -499,11 +521,17 @@ export const GameProvider = ({
null,
);
const [ numberFormat, setNumberFormat ] = useState<NumberFormat>("suffix");
const [ enableSounds, setEnableSounds ] = useState(false);
const [ enableNotifications, setEnableNotifications ] = useState(false);
const enableSoundsReference = useRef(false);
const enableNotificationsReference = useRef(false);
const stateReference = useRef<GameState | null>(null);
const lastSaveReference = useRef<number>(Date.now());
const isSyncingReference = useRef(false);
const rafReference = useRef<number | null>(null);
const unlockedAchievementsReference = useRef<Array<Achievement>>([]);
const newlyCompletedQuestsCountReference = useRef(0);
const newlyFailedQuestsCountReference = useRef(0);
const signatureReference = useRef<string | null>(
localStorage.getItem("elysium_save_signature"),
);
@@ -561,7 +589,11 @@ export const GameProvider = ({
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
const profile = (await response.json()) as {
profileSettings?: { numberFormat?: NumberFormat };
profileSettings?: {
enableNotifications?: boolean;
enableSounds?: boolean;
numberFormat?: NumberFormat;
};
};
const fmt = profile.profileSettings?.numberFormat;
if (
@@ -571,10 +603,14 @@ export const GameProvider = ({
) {
setNumberFormat(fmt);
}
setEnableSounds(profile.profileSettings?.enableSounds === true);
setEnableNotifications(
profile.profileSettings?.enableNotifications === true,
);
}).
catch(() => {
/* Fall back to default "suffix" */
/* Fall back to defaults */
});
} catch (error_: unknown) {
setError(
@@ -589,6 +625,14 @@ export const GameProvider = ({
reloadReference.current = reload;
useEffect(() => {
enableSoundsReference.current = enableSounds;
}, [ enableSounds ]);
useEffect(() => {
enableNotificationsReference.current = enableNotifications;
}, [ enableNotifications ]);
useEffect(() => {
void reload();
}, [ reload ]);
@@ -904,6 +948,27 @@ export const GameProvider = ({
},
);
// Detect newly completed quests
newlyCompletedQuestsCountReference.current = next.quests.filter(
(q, index) => {
return (
previous.quests[index]?.status === "active"
&& q.status === "completed"
);
},
).length;
// Detect newly failed quests
newlyFailedQuestsCountReference.current = next.quests.filter(
(q, index) => {
const previousFailedAt = previous.quests[index]?.lastFailedAt;
return (
q.lastFailedAt !== undefined
&& q.lastFailedAt !== previousFailedAt
);
},
).length;
return next;
});
@@ -911,9 +976,37 @@ export const GameProvider = ({
setUnlockedAchievements((previous) => {
return [ ...previous, ...unlockedAchievementsReference.current ];
});
if (enableSoundsReference.current) {
playSound("achievement");
}
if (enableNotificationsReference.current) {
for (const achievement of unlockedAchievementsReference.current) {
sendNotification("🏆 Achievement Unlocked!", achievement.name);
}
}
unlockedAchievementsReference.current = [];
}
if (newlyCompletedQuestsCountReference.current > 0) {
if (enableSoundsReference.current) {
playSound("questCompleted");
}
if (enableNotificationsReference.current) {
sendNotification("📜 Quest Complete!", "A quest has been completed.");
}
newlyCompletedQuestsCountReference.current = 0;
}
if (newlyFailedQuestsCountReference.current > 0) {
if (enableSoundsReference.current) {
playSound("questFailed");
}
if (enableNotificationsReference.current) {
sendNotification("💀 Quest Failed!", "A quest has failed.");
}
newlyFailedQuestsCountReference.current = 0;
}
// Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions)
if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) {
lastSaveReference.current = Date.now();
@@ -961,6 +1054,12 @@ export const GameProvider = ({
isAutoPrestigingReference.current = true;
void prestigeApi({}).
then(async() => {
if (enableSoundsReference.current) {
playSound("prestige");
}
if (enableNotificationsReference.current) {
sendNotification("⭐ Prestige!", "You have ascended!");
}
await reloadReference.current();
}).
catch(() => {
@@ -1004,6 +1103,17 @@ export const GameProvider = ({
return applyBossResult(previous, bossId, result);
});
setBattleResult({ bossName, result });
if (result.won) {
if (enableSoundsReference.current) {
playSound("bossVictory");
}
if (enableNotificationsReference.current) {
sendNotification(
"⚔️ Boss Defeated!",
`You defeated ${bossName}!`,
);
}
}
}).
catch(() => {
@@ -1333,12 +1443,24 @@ export const GameProvider = ({
const transcend = useCallback(async() => {
const result = await transcendApi({});
if (enableSoundsReference.current) {
playSound("transcendence");
}
if (enableNotificationsReference.current) {
sendNotification("🌌 Transcendence!", "You have transcended reality!");
}
await reload();
return result;
}, [ reload ]);
const apotheosis = useCallback(async() => {
const result = await achieveApotheosisApi({});
if (enableSoundsReference.current) {
playSound("apotheosis");
}
if (enableNotificationsReference.current) {
sendNotification("✨ Apotheosis!", "You have achieved godhood!");
}
await reload();
return result;
}, [ reload ]);
@@ -1589,6 +1711,14 @@ export const GameProvider = ({
return applyBossResult(previous, bossId, result);
});
setBattleResult({ bossName: boss.name, result: result });
if (result.won) {
if (enableSoundsReference.current) {
playSound("bossVictory");
}
if (enableNotificationsReference.current) {
sendNotification("⚔️ Boss Defeated!", `You defeated ${boss.name}!`);
}
}
} catch {
// Silently ignore — server errors shouldn't crash the UI
}
@@ -1707,6 +1837,8 @@ export const GameProvider = ({
dismissLoginBonus,
dismissOfflineGold,
dismissStoryChapter,
enableNotifications,
enableSounds,
equipItem,
error,
forceSync,
@@ -1725,6 +1857,8 @@ export const GameProvider = ({
saveSchemaVersion,
schemaOutdated,
setActiveCompanion,
setEnableNotifications,
setEnableSounds,
setNumberFormat,
startExploration,
startQuest,
@@ -1758,6 +1892,8 @@ export const GameProvider = ({
dismissLoginBonus,
dismissOfflineGold,
dismissStoryChapter,
enableNotifications,
enableSounds,
equipItem,
error,
forceSync,
@@ -1775,6 +1911,8 @@ export const GameProvider = ({
saveSchemaVersion,
schemaOutdated,
setActiveCompanion,
setEnableNotifications,
setEnableSounds,
setNumberFormat,
startExploration,
startQuest,
+46
View File
@@ -0,0 +1,46 @@
/**
* @file Browser notification utilities.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/**
* Requests browser notification permission from the user.
* @returns Whether permission was granted.
*/
const requestNotificationPermission = async(): Promise<boolean> => {
if (typeof Notification === "undefined") {
return false;
}
if (Notification.permission === "granted") {
return true;
}
if (Notification.permission === "denied") {
return false;
}
const permission = await Notification.requestPermission();
return permission === "granted";
};
/**
* Sends a browser notification if permission has been granted.
* @param title - The notification title text.
* @param body - The notification message displayed below the title.
*/
const sendNotification = (title: string, body: string): void => {
if (
typeof Notification === "undefined"
|| Notification.permission !== "granted"
) {
return;
}
try {
// eslint-disable-next-line no-new -- Notification constructor has side effects
new Notification(title, { body: body, icon: "/favicon.ico" });
} catch {
// Silently ignore — notifications may fail silently
}
};
export { requestNotificationPermission, sendNotification };
+110
View File
@@ -0,0 +1,110 @@
/**
* @file Sound effect utilities using the Web Audio API.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type SoundEvent =
| "achievement"
| "apotheosis"
| "bossVictory"
| "prestige"
| "questCompleted"
| "questFailed"
| "transcendence";
interface SoundPattern {
frequencies: Array<number>;
gain: number;
noteDuration: number;
type: OscillatorType;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
const SOUND_PATTERNS: Record<SoundEvent, SoundPattern> = {
achievement: {
frequencies: [ 523, 659, 784, 1047 ],
gain: 0.3,
noteDuration: 0.12,
type: "triangle",
},
apotheosis: {
frequencies: [ 1047, 880, 784, 659, 523 ],
gain: 0.35,
noteDuration: 0.25,
type: "sine",
},
bossVictory: {
frequencies: [ 523, 784, 1047 ],
gain: 0.4,
noteDuration: 0.18,
type: "square",
},
prestige: {
frequencies: [ 392, 523, 659, 784 ],
gain: 0.35,
noteDuration: 0.15,
type: "sawtooth",
},
questCompleted: {
frequencies: [ 523, 659 ],
gain: 0.25,
noteDuration: 0.15,
type: "sine",
},
questFailed: {
frequencies: [ 392, 330, 261 ],
gain: 0.25,
noteDuration: 0.18,
type: "triangle",
},
transcendence: {
frequencies: [ 261, 329, 392, 523 ],
gain: 0.3,
noteDuration: 0.3,
type: "sine",
},
};
// eslint-disable-next-line @typescript-eslint/init-declarations -- lazily initialised on first use
let audioContext: AudioContext | undefined;
const getAudioContext = (): AudioContext => {
if (audioContext === undefined) {
audioContext = new AudioContext();
}
return audioContext;
};
/**
* Plays a sound effect for a given game event using the Web Audio API.
* @param event - The game event to play a sound for.
*/
const playSound = (event: SoundEvent): void => {
try {
const context = getAudioContext();
const pattern = SOUND_PATTERNS[event];
for (const [ index, frequency ] of pattern.frequencies.entries()) {
const oscillator = context.createOscillator();
const gainNode = context.createGain();
oscillator.connect(gainNode);
gainNode.connect(context.destination);
oscillator.type = pattern.type;
oscillator.frequency.value = frequency;
const noteOffset = index * pattern.noteDuration;
const startTime = context.currentTime + noteOffset;
const endTime = startTime + pattern.noteDuration;
gainNode.gain.setValueAtTime(0, startTime);
gainNode.gain.linearRampToValueAtTime(pattern.gain, startTime + 0.01);
gainNode.gain.linearRampToValueAtTime(0, endTime);
oscillator.start(startTime);
oscillator.stop(endTime);
}
} catch {
// Silently ignore — audio may not be available in all environments
}
};
export type { SoundEvent };
export { playSound };
@@ -38,10 +38,22 @@ interface ProfileSettings {
* Whether this player appears on the public leaderboards.
*/
showOnLeaderboards: boolean;
/**
* Whether in-game sound effects are enabled.
*/
enableSounds: boolean;
/**
* Whether browser system notifications are enabled.
*/
enableNotifications: boolean;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
enableNotifications: false,
enableSounds: false,
numberFormat: "suffix",
showAchievementsUnlocked: true,
showAdventurersRecruited: true,
@@ -20,6 +20,8 @@ describe("DEFAULT_PROFILE_SETTINGS", () => {
expect(DEFAULT_PROFILE_SETTINGS.showAdventurersRecruited).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showAchievementsUnlocked).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.showOnLeaderboards).toBe(true);
expect(DEFAULT_PROFILE_SETTINGS.enableSounds).toBe(false);
expect(DEFAULT_PROFILE_SETTINGS.enableNotifications).toBe(false);
});
it("defaults numberFormat to suffix", () => {