Compare commits

5 Commits

Author SHA1 Message Date
josh ea3951aa0c Cleanup: extract constants, fix typecheck, add ESLint, organize store types
Balance Check / balance-simulation (push) Successful in 37s
Balance Check / multi-run-balance (push) Successful in 13m39s
CI / build-and-push (push) Failing after 19s
- Remove unused initCount state from useAuthGate hook
- Replace magic number with MAX_SAVES_PER_USER constant in saves route
- Extract duplicated EMAIL_REGEX and MIN_PASSWORD_LENGTH in auth routes
- Fix game-simulation typecheck failure by adding DOM lib to tsconfig
- Extract store UI types (ActivePage, InfraNav, etc.) to store/types.ts
- Fix let→const for non-reassigned arrays in servingPipeline
- Fix useless initial assignments in reputationSystem
- Fix ambiguous multiline array access in sanityChecks
- Add minimal ESLint config with typescript-eslint
- Add .planning/ and *.log to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 22:45:32 -04:00
josh 6ea136083a Overhaul cloud save system: fix destructive bugs, add save management UI
CI / build-and-push (push) Successful in 57s
Stop 401 responses from wiping local saves and force-reloading. Fix logout
race condition with final cloud save before token invalidation. Replace
hard 3-failure cap with exponential backoff (2min to 30min). Switch cloud
save interval from tick-based (30s) to wall-clock (5min). Add cloud save
status indicator and Force Save button in TopBar. Show save conflict
dialog on login when both local and cloud saves exist. Add cloud save
list, download, and delete in Settings. Server now keeps 10 save snapshots
per user instead of overwriting a single save.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 21:43:00 -04:00
josh 8ef1226755 Fix logout resetting progress: auto-load cloud save after re-login
CI / build-and-push (push) Successful in 39s
After logout and re-login, the cloud save was never fetched because
useAuthGate.init() had already run with an anonymous token. Now
handleSetRegistered fetches and restores the cloud save when the user
becomes registered, so they return directly to their game.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 21:17:54 -04:00
josh f3e6a2e692 Fix background music: bright lo-fi pad instead of horror ambient
CI / build-and-push (push) Successful in 46s
Moved chords up an octave (C4-E5 range), switched to triangle waves,
faster LFO rates, all major voicings, and higher filter cutoff. The
previous version with sub-bass sine drones and ultra-slow modulation
was genuinely terrifying.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 20:20:43 -04:00
josh d609934b73 Add working sound effects and background music via Web Audio API
CI / build-and-push (push) Successful in 1m17s
Synthesized audio system with 9 distinct SFX (click, success, warning,
danger, purchase, achievement, era transition, info) mapped to all game
notifications, plus generative ambient background music with chord
progressions. Adds SFX volume slider to settings alongside existing
music volume control. No audio files or npm dependencies needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 20:12:05 -04:00
28 changed files with 2074 additions and 118 deletions
+2
View File
@@ -10,3 +10,5 @@ balance-report*.json
balance-metrics*.csv balance-metrics*.csv
multirun-summary.csv multirun-summary.csv
multirun-timeseries.csv multirun-timeseries.csv
.planning/
*.log
+9 -6
View File
@@ -7,6 +7,9 @@ import { createToken } from '../lib/jwt';
import { authMiddleware } from '../middleware/auth'; import { authMiddleware } from '../middleware/auth';
import type { AppEnv } from '../types'; import type { AppEnv } from '../types';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MIN_PASSWORD_LENGTH = 8;
const auth = new Hono<AppEnv>(); const auth = new Hono<AppEnv>();
auth.post('/anonymous', async (c) => { auth.post('/anonymous', async (c) => {
@@ -27,11 +30,11 @@ auth.post('/register', authMiddleware, async (c) => {
inviteCode: string; inviteCode: string;
}>(); }>();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (!email || !EMAIL_REGEX.test(email)) {
return c.json({ error: 'Valid email required' }, 400); return c.json({ error: 'Valid email required' }, 400);
} }
if (!password || password.length < 8) { if (!password || password.length < MIN_PASSWORD_LENGTH) {
return c.json({ error: 'Password must be at least 8 characters' }, 400); return c.json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` }, 400);
} }
if (process.env.REQUIRE_INVITE !== 'false') { if (process.env.REQUIRE_INVITE !== 'false') {
@@ -117,8 +120,8 @@ auth.post('/change-password', authMiddleware, async (c) => {
newPassword: string; newPassword: string;
}>(); }>();
if (!newPassword || newPassword.length < 8) { if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) {
return c.json({ error: 'New password must be at least 8 characters' }, 400); return c.json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` }, 400);
} }
if (!user.mustResetPassword) { if (!user.mustResetPassword) {
@@ -193,7 +196,7 @@ auth.post('/change-email', authMiddleware, async (c) => {
currentPassword: string; currentPassword: string;
}>(); }>();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (!email || !EMAIL_REGEX.test(email)) {
return c.json({ error: 'Valid email required' }, 400); return c.json({ error: 'Valid email required' }, 400);
} }
+18 -25
View File
@@ -1,10 +1,12 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { eq, and, desc } from 'drizzle-orm'; import { eq, and, desc, notInArray } from 'drizzle-orm';
import { db } from '../db'; import { db } from '../db';
import { saves } from '../db/schema'; import { saves } from '../db/schema';
import { authMiddleware } from '../middleware/auth'; import { authMiddleware } from '../middleware/auth';
import type { AppEnv } from '../types'; import type { AppEnv } from '../types';
const MAX_SAVES_PER_USER = 10;
const savesRouter = new Hono<AppEnv>(); const savesRouter = new Hono<AppEnv>();
savesRouter.use('*', authMiddleware); savesRouter.use('*', authMiddleware);
@@ -23,7 +25,7 @@ savesRouter.get('/', async (c) => {
.from(saves) .from(saves)
.where(eq(saves.userId, userId)) .where(eq(saves.userId, userId))
.orderBy(desc(saves.updatedAt)) .orderBy(desc(saves.updatedAt))
.limit(10); .limit(MAX_SAVES_PER_USER);
return c.json({ saves: userSaves }); return c.json({ saves: userSaves });
}); });
@@ -68,29 +70,6 @@ savesRouter.put('/', async (c) => {
era: string; era: string;
}>(); }>();
const existing = await db
.select({ id: saves.id })
.from(saves)
.where(eq(saves.userId, userId))
.orderBy(desc(saves.updatedAt))
.limit(1);
if (existing.length > 0) {
await db
.update(saves)
.set({
companyName: body.companyName,
saveVersion: body.saveVersion,
gameData: body.gameData,
tickCount: body.tickCount,
era: body.era,
updatedAt: new Date(),
})
.where(eq(saves.id, existing[0].id));
return c.json({ id: existing[0].id, updated: true });
}
const [newSave] = await db const [newSave] = await db
.insert(saves) .insert(saves)
.values({ .values({
@@ -103,6 +82,20 @@ savesRouter.put('/', async (c) => {
}) })
.returning({ id: saves.id }); .returning({ id: saves.id });
const keepIds = await db
.select({ id: saves.id })
.from(saves)
.where(eq(saves.userId, userId))
.orderBy(desc(saves.updatedAt))
.limit(MAX_SAVES_PER_USER);
const keepSet = keepIds.map((r) => r.id);
if (keepSet.length === MAX_SAVES_PER_USER) {
await db
.delete(saves)
.where(and(eq(saves.userId, userId), notInArray(saves.id, keepSet)));
}
return c.json({ id: newSave.id, created: true }); return c.json({ id: newSave.id, created: true });
}); });
+17 -2
View File
@@ -4,6 +4,7 @@ import { MainLayout } from '@/components/layout/MainLayout';
import { NewGameScreen } from '@/components/game/NewGameScreen'; import { NewGameScreen } from '@/components/game/NewGameScreen';
import { OfflineCatchUp } from '@/components/game/OfflineCatchUp'; import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
import { InviteGateScreen } from '@/components/game/InviteGateScreen'; import { InviteGateScreen } from '@/components/game/InviteGateScreen';
import { SaveConflictDialog } from '@/components/game/SaveConflictDialog';
import { useGameLoop } from '@/hooks/useGameLoop'; import { useGameLoop } from '@/hooks/useGameLoop';
import { useAuthGate } from '@/hooks/useAuthGate'; import { useAuthGate } from '@/hooks/useAuthGate';
import { useCloudSave } from '@/hooks/useCloudSave'; import { useCloudSave } from '@/hooks/useCloudSave';
@@ -54,8 +55,10 @@ function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () =>
} }
export function App() { export function App() {
const { loading: authLoading, backendError, needsInvite, needsPasswordReset, cloudSave, loadCloudSave, setRegistered, setNeedsPasswordReset, retry } = useAuthGate(); const { loading: authLoading, backendError, needsInvite, needsPasswordReset, cloudSave, hasConflict, loadCloudSave, resolveConflict, setRegistered, setNeedsPasswordReset, retry } = useAuthGate();
const companyName = useGameStore((s) => s.meta.companyName); const companyName = useGameStore((s) => s.meta.companyName);
const currentEra = useGameStore((s) => s.meta.currentEra);
const tickCount = useGameStore((s) => s.meta.tickCount);
const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp); const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp);
const [catchUpTicks, setCatchUpTicks] = useState<number | null>(null); const [catchUpTicks, setCatchUpTicks] = useState<number | null>(null);
const [catchUpDone, setCatchUpDone] = useState(false); const [catchUpDone, setCatchUpDone] = useState(false);
@@ -71,7 +74,7 @@ export function App() {
} }
}, [companyName, lastTickTimestamp, catchUpDone]); }, [companyName, lastTickTimestamp, catchUpDone]);
useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset); useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset || hasConflict);
useCloudSave(); useCloudSave();
if (authLoading) { if (authLoading) {
@@ -93,6 +96,18 @@ export function App() {
); );
} }
if (hasConflict && cloudSave && companyName) {
return (
<SaveConflictDialog
localSave={{ companyName, era: currentEra, tickCount, lastTickTimestamp }}
cloudSave={cloudSave}
onChooseLocal={() => resolveConflict('local')}
onChooseCloud={() => resolveConflict('cloud')}
onNewGame={() => resolveConflict('new')}
/>
);
}
if (!companyName) { if (!companyName) {
return <NewGameScreen cloudSave={cloudSave} onContinue={loadCloudSave} />; return <NewGameScreen cloudSave={cloudSave} onContinue={loadCloudSave} />;
} }
+126
View File
@@ -0,0 +1,126 @@
import type { SoundId } from './synthesizer';
import { playSound } from './synthesizer';
import { MusicEngine } from './musicEngine';
export class AudioManager {
private static instance: AudioManager | null = null;
private ctx: AudioContext | null = null;
private masterGain: GainNode | null = null;
private sfxGain: GainNode | null = null;
private musicGain: GainNode | null = null;
private music: MusicEngine | null = null;
private musicPlaying = false;
private soundEnabled = true;
private sfxVol = 0.5;
private musicVol = 0.5;
private lastPlayed = new Map<SoundId, number>();
static getInstance(): AudioManager {
if (!AudioManager.instance) {
AudioManager.instance = new AudioManager();
}
return AudioManager.instance;
}
private ensureContext(): AudioContext {
if (!this.ctx) {
this.ctx = new AudioContext();
this.masterGain = this.ctx.createGain();
this.masterGain.connect(this.ctx.destination);
this.sfxGain = this.ctx.createGain();
this.sfxGain.connect(this.masterGain);
this.sfxGain.gain.value = this.sfxVol;
this.musicGain = this.ctx.createGain();
this.musicGain.connect(this.masterGain);
this.musicGain.gain.value = this.musicVol;
this.masterGain.gain.value = this.soundEnabled ? 1 : 0;
}
if (this.ctx.state === 'suspended') {
this.ctx.resume();
}
return this.ctx;
}
setSoundEnabled(enabled: boolean): void {
this.soundEnabled = enabled;
if (this.masterGain && this.ctx) {
const now = this.ctx.currentTime;
this.masterGain.gain.cancelScheduledValues(now);
this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now);
this.masterGain.gain.linearRampToValueAtTime(enabled ? 1 : 0, now + 0.05);
}
}
setSfxVolume(v: number): void {
this.sfxVol = v;
if (this.sfxGain && this.ctx) {
const now = this.ctx.currentTime;
this.sfxGain.gain.cancelScheduledValues(now);
this.sfxGain.gain.setValueAtTime(this.sfxGain.gain.value, now);
this.sfxGain.gain.linearRampToValueAtTime(v, now + 0.05);
}
}
setMusicVolume(v: number): void {
this.musicVol = v;
if (this.musicGain && this.ctx) {
const now = this.ctx.currentTime;
this.musicGain.gain.cancelScheduledValues(now);
this.musicGain.gain.setValueAtTime(this.musicGain.gain.value, now);
this.musicGain.gain.linearRampToValueAtTime(v, now + 0.2);
}
}
playSfx(soundId: SoundId): void {
if (!this.soundEnabled || this.sfxVol === 0) return;
const now = performance.now();
const last = this.lastPlayed.get(soundId) ?? 0;
if (now - last < 100) return;
this.lastPlayed.set(soundId, now);
const ctx = this.ensureContext();
playSound(ctx, this.sfxGain!, soundId);
}
startMusic(): void {
if (this.musicPlaying) return;
const ctx = this.ensureContext();
this.music = new MusicEngine(ctx, this.musicGain!);
this.music.start();
this.musicPlaying = true;
}
stopMusic(): void {
if (!this.musicPlaying || !this.music) return;
this.music.stop();
this.music = null;
this.musicPlaying = false;
}
get isMusicPlaying(): boolean {
return this.musicPlaying;
}
get isContextActive(): boolean {
return this.ctx !== null && this.ctx.state === 'running';
}
dispose(): void {
this.stopMusic();
if (this.ctx) {
this.ctx.close();
this.ctx = null;
this.masterGain = null;
this.sfxGain = null;
this.musicGain = null;
}
this.lastPlayed.clear();
AudioManager.instance = null;
}
}
+69
View File
@@ -0,0 +1,69 @@
import { AudioManager } from './AudioManager';
import type { GameSettings } from '@token-empire/shared';
import { useGameStore } from '@/store';
export { AudioManager } from './AudioManager';
export { triggerNotificationSound, playUISound } from './sounds';
export type { SoundId } from './synthesizer';
function applyVolumes(audio: AudioManager, settings: GameSettings): void {
audio.setSoundEnabled(settings.soundEnabled);
audio.setMusicVolume(settings.musicVolume);
audio.setSfxVolume(settings.sfxVolume ?? 0.5);
}
function shouldPlayMusic(settings: GameSettings): boolean {
return settings.soundEnabled && settings.musicVolume > 0;
}
export function initAudioSystem(): () => void {
const audio = AudioManager.getInstance();
let gestureListenerActive = false;
const settings = useGameStore.getState().meta.settings;
applyVolumes(audio, settings);
// Music requires a user gesture to start (browser autoplay policy).
// We register a one-shot listener that starts music on first click/key.
const startOnGesture = () => {
const s = useGameStore.getState().meta.settings;
if (shouldPlayMusic(s)) {
audio.startMusic();
}
document.removeEventListener('click', startOnGesture);
document.removeEventListener('keydown', startOnGesture);
gestureListenerActive = false;
};
if (shouldPlayMusic(settings)) {
document.addEventListener('click', startOnGesture);
document.addEventListener('keydown', startOnGesture);
gestureListenerActive = true;
}
const unsub = useGameStore.subscribe((state) => {
const next = state.meta.settings;
applyVolumes(audio, next);
if (shouldPlayMusic(next)) {
if (!audio.isMusicPlaying) {
if (audio.isContextActive) {
audio.startMusic();
} else if (!gestureListenerActive) {
document.addEventListener('click', startOnGesture);
document.addEventListener('keydown', startOnGesture);
gestureListenerActive = true;
}
}
} else {
audio.stopMusic();
}
});
return () => {
unsub();
document.removeEventListener('click', startOnGesture);
document.removeEventListener('keydown', startOnGesture);
audio.dispose();
};
}
+157
View File
@@ -0,0 +1,157 @@
// Bright, lo-fi ambient pad in C major — pleasant background for a tech management game.
// Higher register, all major/bright voicings, gentle triangle waves, moderate LFO rates.
const CHORDS: number[][] = [
[261.63, 329.63, 392.00, 493.88], // Cmaj7: C4, E4, G4, B4
[349.23, 440.00, 523.25, 659.25], // Fmaj7: F4, A4, C5, E5
[293.66, 369.99, 440.00, 523.25], // Dm7: D4, F#4, A4, C5
[196.00, 246.94, 293.66, 392.00], // Gadd9: G3, B3, D4, G4
];
const CHORD_DURATION = 24;
export class MusicEngine {
private ctx: AudioContext;
private dest: AudioNode;
private oscs: OscillatorNode[] = [];
private lfos: OscillatorNode[] = [];
private gains: GainNode[] = [];
private shimmerOsc: OscillatorNode | null = null;
private shimmerGain: GainNode | null = null;
private shimmerLfo: OscillatorNode | null = null;
private filter: BiquadFilterNode | null = null;
private filterLfo: OscillatorNode | null = null;
private filterLfoGain: GainNode | null = null;
private chordIndex = 0;
private timer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(ctx: AudioContext, dest: AudioNode) {
this.ctx = ctx;
this.dest = dest;
}
start(): void {
if (this.running) return;
this.running = true;
// Warm lowpass keeps it soft
this.filter = this.ctx.createBiquadFilter();
this.filter.type = 'lowpass';
this.filter.frequency.value = 2200;
this.filter.Q.value = 0.3;
this.filter.connect(this.dest);
// Gentle filter movement
this.filterLfo = this.ctx.createOscillator();
this.filterLfoGain = this.ctx.createGain();
this.filterLfo.type = 'sine';
this.filterLfo.frequency.value = 0.04;
this.filterLfoGain.gain.value = 400;
this.filterLfo.connect(this.filterLfoGain).connect(this.filter.frequency);
this.filterLfo.start();
// Chord pad — triangle waves for warmth without the drone menace
const chord = CHORDS[0];
for (let i = 0; i < chord.length; i++) {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const lfo = this.ctx.createOscillator();
const lfoGain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = chord[i];
gain.gain.value = 0.03;
// Moderate breathing — fast enough to feel alive, not ominous
lfo.type = 'sine';
lfo.frequency.value = 0.12 + i * 0.04;
lfoGain.gain.value = 0.015;
lfo.connect(lfoGain).connect(gain.gain);
osc.connect(gain).connect(this.filter!);
osc.start();
lfo.start();
this.oscs.push(osc);
this.lfos.push(lfo);
this.gains.push(gain);
}
// High sparkle layer
this.shimmerOsc = this.ctx.createOscillator();
this.shimmerGain = this.ctx.createGain();
this.shimmerLfo = this.ctx.createOscillator();
const shimmerLfoGain = this.ctx.createGain();
this.shimmerOsc.type = 'sine';
this.shimmerOsc.frequency.value = 1046.50; // C6
this.shimmerGain.gain.value = 0.008;
this.shimmerLfo.type = 'sine';
this.shimmerLfo.frequency.value = 0.06;
shimmerLfoGain.gain.value = 0.007;
this.shimmerLfo.connect(shimmerLfoGain).connect(this.shimmerGain.gain);
this.shimmerOsc.connect(this.shimmerGain).connect(this.filter!);
this.shimmerOsc.start();
this.shimmerLfo.start();
this.lfos.push(this.shimmerLfo);
this.chordIndex = 0;
this.timer = setInterval(() => this.nextChord(), CHORD_DURATION * 1000);
}
private nextChord(): void {
this.chordIndex = (this.chordIndex + 1) % CHORDS.length;
const chord = CHORDS[this.chordIndex];
const now = this.ctx.currentTime;
for (let i = 0; i < chord.length; i++) {
const osc = this.oscs[i];
osc.frequency.cancelScheduledValues(now);
osc.frequency.setValueAtTime(osc.frequency.value, now);
osc.frequency.linearRampToValueAtTime(chord[i], now + 3);
}
if (this.shimmerOsc) {
const shimmerFreq = chord[2] * 2; // Fifth of chord, up an octave
this.shimmerOsc.frequency.cancelScheduledValues(now);
this.shimmerOsc.frequency.setValueAtTime(this.shimmerOsc.frequency.value, now);
this.shimmerOsc.frequency.linearRampToValueAtTime(shimmerFreq, now + 3);
}
}
stop(): void {
if (!this.running) return;
this.running = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
for (const osc of this.oscs) {
try { osc.stop(); } catch { /* already stopped */ }
}
for (const lfo of this.lfos) {
try { lfo.stop(); } catch { /* already stopped */ }
}
if (this.shimmerOsc) {
try { this.shimmerOsc.stop(); } catch { /* already stopped */ }
}
if (this.filterLfo) {
try { this.filterLfo.stop(); } catch { /* already stopped */ }
}
this.oscs = [];
this.lfos = [];
this.gains = [];
this.shimmerOsc = null;
this.shimmerGain = null;
this.shimmerLfo = null;
this.filter = null;
this.filterLfo = null;
this.filterLfoGain = null;
}
}
+54
View File
@@ -0,0 +1,54 @@
import { AudioManager } from './AudioManager';
import type { SoundId } from './synthesizer';
export type { SoundId } from './synthesizer';
const NOTIFICATION_SOUND_MAP: Record<string, SoundId> = {
'Training Complete': 'success-major',
'Research Complete': 'success',
'Model Deployed': 'success-major',
'Variant Deployed': 'success',
'Cluster Online': 'success',
'Campus Ready': 'success',
'Data Center Online': 'success',
'Retrofit Complete': 'success',
'Campus Retrofit Complete': 'success',
'Breakthrough!': 'success-major',
'Variant Created': 'success',
'Model Open Sourced': 'purchase',
'Loss Spike': 'warning',
'Training Instability': 'warning',
'Hardware Failure': 'warning',
'Network Switch Failure': 'warning',
'Safety Incident!': 'danger',
'Core Network Failure': 'danger',
'Data Contamination': 'danger',
'Training Started': 'info',
'Quantization Started': 'info',
'Pre-training Complete': 'info',
'SFT Complete': 'info',
'Achievement Unlocked!': 'achievement',
'Era Transition!': 'era',
};
const TYPE_FALLBACK: Record<string, SoundId> = {
success: 'success',
warning: 'warning',
danger: 'danger',
info: 'info',
};
export function triggerNotificationSound(n: { title: string; type: string }): void {
const soundId = NOTIFICATION_SOUND_MAP[n.title] ?? TYPE_FALLBACK[n.type];
if (soundId) {
AudioManager.getInstance().playSfx(soundId);
}
}
export function playUISound(soundId: SoundId = 'click'): void {
AudioManager.getInstance().playSfx(soundId);
}
+267
View File
@@ -0,0 +1,267 @@
export type SoundId =
| 'click'
| 'success'
| 'success-major'
| 'warning'
| 'danger'
| 'purchase'
| 'achievement'
| 'era'
| 'info';
export function playSound(ctx: AudioContext, dest: AudioNode, id: SoundId): void {
const fn = SOUNDS[id];
if (fn) fn(ctx, dest);
}
type SynthFn = (ctx: AudioContext, dest: AudioNode) => void;
const SOUNDS: Record<SoundId, SynthFn> = {
click: playClick,
success: playSuccess,
'success-major': playSuccessMajor,
warning: playWarning,
danger: playDanger,
purchase: playPurchase,
achievement: playAchievement,
era: playEra,
info: playInfo,
};
function playClick(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = 800;
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
osc.connect(gain).connect(dest);
osc.start(now);
osc.stop(now + 0.06);
}
function playSuccess(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const notes = [523.25, 659.25]; // C5, E5
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
const start = now + i * 0.1;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.25, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.15);
osc.connect(gain).connect(dest);
osc.start(start);
osc.stop(start + 0.16);
});
}
function playSuccessMajor(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const notes = [523.25, 659.25, 783.99]; // C5, E5, G5
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 2000;
osc.type = 'sawtooth';
osc.frequency.value = freq;
const start = now + i * 0.09;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.2, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.2);
osc.connect(filter).connect(gain).connect(dest);
osc.start(start);
osc.stop(start + 0.22);
});
}
function playWarning(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const osc1 = ctx.createOscillator();
const osc2 = ctx.createOscillator();
const gain = ctx.createGain();
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
osc1.type = 'square';
osc1.frequency.value = 300;
osc2.type = 'square';
osc2.frequency.value = 305;
lfo.type = 'sine';
lfo.frequency.value = 8;
lfoGain.gain.value = 0.15;
lfo.connect(lfoGain).connect(gain.gain);
gain.gain.setValueAtTime(0.15, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
osc1.connect(gain);
osc2.connect(gain);
gain.connect(dest);
osc1.start(now);
osc2.start(now);
lfo.start(now);
osc1.stop(now + 0.26);
osc2.stop(now + 0.26);
lfo.stop(now + 0.26);
}
function playDanger(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
// Sub-bass hit
const sub = ctx.createOscillator();
const subGain = ctx.createGain();
sub.type = 'sine';
sub.frequency.value = 150;
subGain.gain.setValueAtTime(0.3, now);
subGain.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
sub.connect(subGain).connect(dest);
sub.start(now);
sub.stop(now + 0.36);
// Noise burst via oscillator with rapid detuning
const noise = ctx.createOscillator();
const noiseGain = ctx.createGain();
const noiseFilter = ctx.createBiquadFilter();
noise.type = 'sawtooth';
noise.frequency.value = 80;
noise.detune.value = 1200;
noiseFilter.type = 'lowpass';
noiseFilter.frequency.value = 400;
noiseGain.gain.setValueAtTime(0.2, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
noise.connect(noiseFilter).connect(noiseGain).connect(dest);
noise.start(now);
noise.stop(now + 0.16);
}
function playPurchase(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
// Pitch slide
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(1200, now);
osc.frequency.exponentialRampToValueAtTime(800, now + 0.1);
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
osc.connect(gain).connect(dest);
osc.start(now);
osc.stop(now + 0.13);
// Short noise tick
const tick = ctx.createOscillator();
const tickGain = ctx.createGain();
tick.type = 'square';
tick.frequency.value = 3000;
tickGain.gain.setValueAtTime(0.1, now);
tickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.02);
tick.connect(tickGain).connect(dest);
tick.start(now);
tick.stop(now + 0.03);
}
function playAchievement(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const notes = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
const start = now + i * 0.1;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.22, start + 0.02);
gain.gain.setValueAtTime(0.22, start + 0.08);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.25);
osc.connect(gain).connect(dest);
osc.start(start);
osc.stop(start + 0.26);
});
// Echo layer
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
const start = now + i * 0.1 + 0.15;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.07, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.3);
osc.connect(gain).connect(dest);
osc.start(start);
osc.stop(start + 0.31);
});
}
function playEra(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
// Low sine sweep
const sweep = ctx.createOscillator();
const sweepGain = ctx.createGain();
sweep.type = 'sine';
sweep.frequency.setValueAtTime(100, now);
sweep.frequency.exponentialRampToValueAtTime(400, now + 0.6);
sweepGain.gain.setValueAtTime(0, now);
sweepGain.gain.linearRampToValueAtTime(0.25, now + 0.1);
sweepGain.gain.setValueAtTime(0.25, now + 0.5);
sweepGain.gain.exponentialRampToValueAtTime(0.001, now + 0.8);
sweep.connect(sweepGain).connect(dest);
sweep.start(now);
sweep.stop(now + 0.81);
// High shimmer
const shimmer = ctx.createOscillator();
const shimmerGain = ctx.createGain();
const shimmerFilter = ctx.createBiquadFilter();
shimmer.type = 'sawtooth';
shimmer.frequency.value = 2000;
shimmerFilter.type = 'highpass';
shimmerFilter.frequency.value = 4000;
shimmerGain.gain.setValueAtTime(0, now + 0.2);
shimmerGain.gain.linearRampToValueAtTime(0.08, now + 0.35);
shimmerGain.gain.exponentialRampToValueAtTime(0.001, now + 0.7);
shimmer.connect(shimmerFilter).connect(shimmerGain).connect(dest);
shimmer.start(now + 0.2);
shimmer.stop(now + 0.71);
// Impact
const impact = ctx.createOscillator();
const impactGain = ctx.createGain();
impact.type = 'sine';
impact.frequency.value = 200;
impactGain.gain.setValueAtTime(0.3, now + 0.6);
impactGain.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
impact.connect(impactGain).connect(dest);
impact.start(now + 0.6);
impact.stop(now + 1.01);
}
function playInfo(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.18, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
osc.connect(gain).connect(dest);
osc.start(now);
osc.stop(now + 0.09);
}
@@ -0,0 +1,41 @@
import { Cloud, CloudOff, Check, AlertTriangle, Loader2 } from 'lucide-react';
import { useCloudSaveStore, type CloudSaveStatus } from '@/hooks/useCloudSave';
import { Tooltip } from '@/components/common/Tooltip';
function formatTimeAgo(ms: number): string {
const seconds = Math.floor((Date.now() - ms) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
const STATUS_CONFIG: Record<CloudSaveStatus, { icon: typeof Cloud; color: string; label: string }> = {
idle: { icon: Cloud, color: 'text-surface-500', label: 'Cloud save idle' },
saving: { icon: Loader2, color: 'text-accent', label: 'Saving to cloud...' },
success: { icon: Check, color: 'text-success', label: 'Saved to cloud' },
error: { icon: AlertTriangle, color: 'text-warning', label: 'Cloud save failed' },
offline: { icon: CloudOff, color: 'text-surface-500', label: 'Cloud save offline' },
};
export function CloudSaveIndicator({ onForceSave }: { onForceSave: () => void }) {
const status = useCloudSaveStore((s) => s.status);
const lastSaveTime = useCloudSaveStore((s) => s.lastSaveTime);
const config = STATUS_CONFIG[status];
const Icon = config.icon;
const timeLabel = lastSaveTime ? `Last saved: ${formatTimeAgo(lastSaveTime)}` : 'Not yet saved';
return (
<Tooltip content={<div className="space-y-1"><div>{config.label}</div><div className="text-surface-400">{timeLabel}</div><div className="text-surface-500 text-xs">Click to save now</div></div>}>
<button
onClick={onForceSave}
className={`p-2 rounded hover:bg-surface-800 transition-colors ${config.color}`}
aria-label="Cloud save"
>
<Icon size={18} className={status === 'saving' ? 'animate-spin' : ''} />
</button>
</Tooltip>
);
}
@@ -0,0 +1,195 @@
import { useState, useEffect } from 'react';
import { Download, Trash2, Upload, RefreshCw, Cloud } from 'lucide-react';
import { api } from '@/lib/api';
import { useGameStore } from '@/store';
import { formatDuration } from '@token-empire/shared';
import { ConfirmModal } from '@/components/common/ConfirmModal';
interface SaveEntry {
id: string;
companyName: string;
era: string;
tickCount: number;
updatedAt: string;
}
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function CloudSaveList() {
const [saves, setSaves] = useState<SaveEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loadConfirm, setLoadConfirm] = useState<SaveEntry | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<SaveEntry | null>(null);
const addNotification = useGameStore((s) => s.addNotification);
async function fetchSaves() {
setLoading(true);
setError(null);
try {
const { saves: list } = await api.saves.list();
setSaves(list);
} catch {
setError('Failed to load cloud saves');
} finally {
setLoading(false);
}
}
useEffect(() => { fetchSaves(); }, []);
async function handleLoad(save: SaveEntry) {
try {
const { save: full } = await api.saves.get(save.id);
if (full?.gameData) {
useGameStore.setState(full.gameData as Record<string, unknown>);
addNotification({
title: 'Cloud Save Loaded',
message: `Loaded "${save.companyName}" from cloud.`,
type: 'success',
tick: useGameStore.getState().meta.tickCount,
});
}
} catch {
addNotification({
title: 'Load Failed',
message: 'Could not load cloud save.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
setLoadConfirm(null);
}
async function handleDownload(save: SaveEntry) {
try {
const { save: full } = await api.saves.get(save.id);
if (full?.gameData) {
const blob = new Blob([JSON.stringify(full.gameData)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `token-empire-cloud-${save.companyName.replace(/\s+/g, '-').toLowerCase()}.json`;
a.click();
URL.revokeObjectURL(url);
}
} catch {
addNotification({
title: 'Download Failed',
message: 'Could not download cloud save.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
}
async function handleDelete(save: SaveEntry) {
try {
await api.saves.delete(save.id);
setSaves((prev) => prev.filter((s) => s.id !== save.id));
} catch {
addNotification({
title: 'Delete Failed',
message: 'Could not delete cloud save.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
setDeleteConfirm(null);
}
if (loading) {
return (
<div className="flex items-center gap-2 text-sm text-surface-400 py-2">
<RefreshCw size={14} className="animate-spin" /> Loading cloud saves...
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-between text-sm text-surface-400 py-2">
<span>{error}</span>
<button onClick={fetchSaves} className="text-accent hover:text-accent-light text-xs">Retry</button>
</div>
);
}
if (saves.length === 0) {
return (
<div className="flex items-center gap-2 text-sm text-surface-400 py-2">
<Cloud size={14} /> No cloud saves yet.
</div>
);
}
return (
<>
<div className="space-y-2">
{saves.map((save) => (
<div key={save.id} className="flex items-center justify-between bg-surface-800 rounded-lg px-3 py-2 border border-surface-700">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{save.companyName}</div>
<div className="text-xs text-surface-400">
{save.era} &middot; {formatDuration(save.tickCount)} &middot; {timeAgo(save.updatedAt)}
</div>
</div>
<div className="flex items-center gap-1 ml-3 shrink-0">
<button
onClick={() => setLoadConfirm(save)}
className="p-1.5 rounded hover:bg-surface-700 text-surface-400 hover:text-accent transition-colors"
title="Load this save"
>
<Upload size={14} />
</button>
<button
onClick={() => handleDownload(save)}
className="p-1.5 rounded hover:bg-surface-700 text-surface-400 hover:text-surface-200 transition-colors"
title="Download as JSON"
>
<Download size={14} />
</button>
<button
onClick={() => setDeleteConfirm(save)}
className="p-1.5 rounded hover:bg-surface-700 text-surface-400 hover:text-danger transition-colors"
title="Delete this save"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
{loadConfirm && (
<ConfirmModal
title="Load Cloud Save"
message={`Load "${loadConfirm.companyName}"? This will replace your current local game.`}
confirmLabel="Load"
onConfirm={() => handleLoad(loadConfirm)}
onCancel={() => setLoadConfirm(null)}
/>
)}
{deleteConfirm && (
<ConfirmModal
title="Delete Cloud Save"
message={`Delete "${deleteConfirm.companyName}" from the cloud? This cannot be undone.`}
confirmLabel="Delete"
danger
onConfirm={() => handleDelete(deleteConfirm)}
onCancel={() => setDeleteConfirm(null)}
/>
)}
</>
);
}
@@ -0,0 +1,103 @@
import { useEffect } from 'react';
import { Cloud, HardDrive, Plus } from 'lucide-react';
import { formatDuration } from '@token-empire/shared';
import type { CloudSaveInfo } from '@/hooks/useAuthGate';
interface LocalSaveInfo {
companyName: string;
era: string;
tickCount: number;
lastTickTimestamp: number;
}
function SaveCard({ icon: Icon, label, companyName, era, tickCount, timestamp, selected, onClick }: {
icon: typeof Cloud;
label: string;
companyName: string;
era: string;
tickCount: number;
timestamp: string;
selected?: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex-1 p-4 rounded-lg border text-left transition-colors ${
selected
? 'border-accent bg-accent/10'
: 'border-surface-600 bg-surface-800 hover:border-surface-500'
}`}
>
<div className="flex items-center gap-2 mb-3">
<Icon size={16} className={selected ? 'text-accent' : 'text-surface-400'} />
<span className="text-sm font-medium">{label}</span>
</div>
<div className="space-y-1.5">
<div className="text-base font-semibold">{companyName}</div>
<div className="text-xs text-surface-400">Era: {era}</div>
<div className="text-xs text-surface-400">Time: {formatDuration(tickCount)}</div>
<div className="text-xs text-surface-500">{timestamp}</div>
</div>
</button>
);
}
export function SaveConflictDialog({ localSave, cloudSave, onChooseLocal, onChooseCloud, onNewGame }: {
localSave: LocalSaveInfo;
cloudSave: CloudSaveInfo;
onChooseLocal: () => void;
onChooseCloud: () => void;
onNewGame: () => void;
}) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onChooseLocal();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onChooseLocal]);
const localDate = new Date(localSave.lastTickTimestamp).toLocaleString();
const cloudDate = new Date(cloudSave.updatedAt).toLocaleString();
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl">
<h3 className="text-lg font-bold mb-2">Save Conflict</h3>
<p className="text-sm text-surface-400 mb-5">
Both a local save and a cloud save exist. Which would you like to continue with?
</p>
<div className="flex gap-3 mb-5">
<SaveCard
icon={HardDrive}
label="Local Save"
companyName={localSave.companyName}
era={localSave.era}
tickCount={localSave.tickCount}
timestamp={localDate}
onClick={onChooseLocal}
/>
<SaveCard
icon={Cloud}
label="Cloud Save"
companyName={cloudSave.companyName}
era={cloudSave.era}
tickCount={cloudSave.tickCount}
timestamp={cloudDate}
onClick={onChooseCloud}
/>
</div>
<button
onClick={onNewGame}
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded text-sm text-surface-400 hover:text-surface-200 hover:bg-surface-800 transition-colors"
>
<Plus size={14} />
Start New Game
</button>
</div>
</div>
);
}
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar'; import { TopBar } from './TopBar';
import { ToastContainer } from '@/components/common/ToastContainer'; import { ToastContainer } from '@/components/common/ToastContainer';
@@ -5,6 +6,7 @@ import { DevMenu } from '@/components/dev/DevMenu';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { useHashRouter } from '@/hooks/useHashRouter'; import { useHashRouter } from '@/hooks/useHashRouter';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { initAudioSystem } from '@/audio';
import { DashboardPage } from '@/pages/DashboardPage'; import { DashboardPage } from '@/pages/DashboardPage';
import { InfrastructurePage } from '@/pages/InfrastructurePage'; import { InfrastructurePage } from '@/pages/InfrastructurePage';
import { ResearchPage } from '@/pages/ResearchPage'; import { ResearchPage } from '@/pages/ResearchPage';
@@ -23,6 +25,7 @@ import { InvitationsPage } from '@/pages/InvitationsPage';
export function MainLayout() { export function MainLayout() {
const { subPath, setSubPath } = useHashRouter(); const { subPath, setSubPath } = useHashRouter();
useKeyboardShortcuts(); useKeyboardShortcuts();
useEffect(() => initAudioSystem(), []);
const activePage = useGameStore((s) => s.activePage); const activePage = useGameStore((s) => s.activePage);
return ( return (
@@ -1,8 +1,11 @@
import { type ReactNode, useState } from 'react'; import { type ReactNode, useState } from 'react';
import { Pause, Play, Bell, Share2 } from 'lucide-react'; import { Pause, Play, Bell, Share2 } from 'lucide-react';
import { CompanyStatsCard } from '@/components/game/CompanyStatsCard'; import { CompanyStatsCard } from '@/components/game/CompanyStatsCard';
import { CloudSaveIndicator } from '@/components/game/CloudSaveIndicator';
import { NotificationPanel } from '@/components/common/NotificationPanel'; import { NotificationPanel } from '@/components/common/NotificationPanel';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { isRegistered } from '@/lib/api';
import { performCloudSave } from '@/hooks/useCloudSave';
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@token-empire/shared'; import { formatMoney, formatNumber, formatDuration, formatPercent } from '@token-empire/shared';
import type { GameSpeed } from '@token-empire/shared'; import type { GameSpeed } from '@token-empire/shared';
import { Tooltip } from '@/components/common/Tooltip'; import { Tooltip } from '@/components/common/Tooltip';
@@ -97,6 +100,10 @@ export function TopBar() {
))} ))}
</div> </div>
{isRegistered() && (
<CloudSaveIndicator onForceSave={performCloudSave} />
)}
<button <button
onClick={() => setShowStats(true)} onClick={() => setShowStats(true)}
className="p-2 rounded hover:bg-surface-800 transition-colors" className="p-2 rounded hover:bg-surface-800 transition-colors"
+43 -3
View File
@@ -20,7 +20,9 @@ interface AuthGateState {
isAdmin: boolean; isAdmin: boolean;
config: { requireInvite: boolean; userInvitations: number } | null; config: { requireInvite: boolean; userInvitations: number } | null;
cloudSave: CloudSaveInfo | null; cloudSave: CloudSaveInfo | null;
hasConflict: boolean;
loadCloudSave: () => Promise<void>; loadCloudSave: () => Promise<void>;
resolveConflict: (choice: 'local' | 'cloud' | 'new') => Promise<void>;
setRegistered: (value: boolean) => void; setRegistered: (value: boolean) => void;
setNeedsPasswordReset: (value: boolean) => void; setNeedsPasswordReset: (value: boolean) => void;
retry: () => void; retry: () => void;
@@ -34,7 +36,7 @@ export function useAuthGate(): AuthGateState {
const [passwordReset, setPasswordReset] = useState(false); const [passwordReset, setPasswordReset] = useState(false);
const [admin, setAdmin] = useState(false); const [admin, setAdmin] = useState(false);
const [cloudSave, setCloudSave] = useState<CloudSaveInfo | null>(null); const [cloudSave, setCloudSave] = useState<CloudSaveInfo | null>(null);
const [initCount, setInitCount] = useState(0); const [hasConflict, setHasConflict] = useState(false);
const init = useCallback(async () => { const init = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -95,7 +97,6 @@ export function useAuthGate(): AuthGateState {
useState(() => { init(); }); useState(() => { init(); });
const retry = useCallback(() => { const retry = useCallback(() => {
setInitCount(c => c + 1);
init(); init();
}, [init]); }, [init]);
@@ -111,13 +112,50 @@ export function useAuthGate(): AuthGateState {
} }
}, []); }, []);
const handleSetRegistered = useCallback((value: boolean) => { const resolveConflict = useCallback(async (choice: 'local' | 'cloud' | 'new') => {
if (choice === 'cloud') {
await loadCloudSave();
} else if (choice === 'new') {
localStorage.removeItem('token-empire-save');
window.location.reload();
return;
}
setHasConflict(false);
}, [loadCloudSave]);
const handleSetRegistered = useCallback(async (value: boolean) => {
setRegistered(value); setRegistered(value);
const payload = getTokenPayload(); const payload = getTokenPayload();
if (payload) { if (payload) {
setAdmin(payload.role === 'admin'); setAdmin(payload.role === 'admin');
setPasswordReset(payload.mustResetPassword); setPasswordReset(payload.mustResetPassword);
} }
if (value) {
setLoading(true);
try {
const { save } = await api.saves.latest();
if (save && save.tickCount > 0) {
setCloudSave({
id: save.id,
companyName: save.companyName,
era: save.era,
tickCount: save.tickCount,
updatedAt: save.updatedAt,
});
const localCompany = useGameStore.getState().meta.companyName;
if (localCompany) {
setHasConflict(true);
} else {
useGameStore.setState(save.gameData as Record<string, unknown>);
}
}
} catch {
// No cloud save — user sees new game screen
} finally {
setLoading(false);
}
}
}, []); }, []);
const handleSetPasswordReset = useCallback((value: boolean) => { const handleSetPasswordReset = useCallback((value: boolean) => {
@@ -135,7 +173,9 @@ export function useAuthGate(): AuthGateState {
isAdmin: admin, isAdmin: admin,
config, config,
cloudSave, cloudSave,
hasConflict,
loadCloudSave, loadCloudSave,
resolveConflict,
setRegistered: handleSetRegistered, setRegistered: handleSetRegistered,
setNeedsPasswordReset: handleSetPasswordReset, setNeedsPasswordReset: handleSetPasswordReset,
retry, retry,
+99 -31
View File
@@ -1,50 +1,118 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useCallback } from 'react';
import { create } from 'zustand';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload } from '@/lib/api'; import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload, isRegistered } from '@/lib/api';
import { AUTO_SAVE_INTERVAL_TICKS } from '@token-empire/shared';
const MAX_CONSECUTIVE_FAILURES = 3; const CLOUD_SAVE_INTERVAL_MS = 5 * 60 * 1000;
const BASE_BACKOFF_MS = 2 * 60 * 1000;
const MAX_BACKOFF_MS = 30 * 60 * 1000;
export type CloudSaveStatus = 'idle' | 'saving' | 'success' | 'error' | 'offline';
interface CloudSaveState {
status: CloudSaveStatus;
lastSaveTime: number | null;
failureCount: number;
setStatus: (status: CloudSaveStatus) => void;
setLastSaveTime: (time: number) => void;
setFailureCount: (count: number) => void;
}
export const useCloudSaveStore = create<CloudSaveState>((set) => ({
status: 'idle',
lastSaveTime: null,
failureCount: 0,
setStatus: (status) => set({ status }),
setLastSaveTime: (time) => set({ lastSaveTime: time }),
setFailureCount: (count) => set({ failureCount: count }),
}));
function buildSavePayload() {
const state = useGameStore.getState();
const { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
return {
companyName: state.meta.companyName,
saveVersion: state.meta.saveVersion,
gameData: gameState,
tickCount: state.meta.tickCount,
era: state.meta.currentEra,
};
}
export async function performCloudSave(): Promise<boolean> {
const token = getAuthToken();
if (!token || !isRegistered()) return false;
const store = useCloudSaveStore.getState();
store.setStatus('saving');
try {
await api.saves.put(buildSavePayload());
store.setStatus('success');
store.setLastSaveTime(Date.now());
if (store.failureCount > 0) {
useGameStore.getState().addNotification({
title: 'Cloud Save Reconnected',
message: 'Your game is syncing to the cloud again.',
type: 'success',
tick: useGameStore.getState().meta.tickCount,
});
}
store.setFailureCount(0);
return true;
} catch {
const newCount = store.failureCount + 1;
store.setFailureCount(newCount);
store.setStatus('error');
if (newCount === 1) {
useGameStore.getState().addNotification({
title: 'Cloud Save Failed',
message: 'Progress is saved locally. Retrying automatically.',
type: 'warning',
tick: useGameStore.getState().meta.tickCount,
});
} else if (newCount === 5) {
useGameStore.getState().addNotification({
title: 'Cloud Save Unavailable',
message: 'Use Settings → Export Save to back up manually.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
return false;
}
}
export function useCloudSave() { export function useCloudSave() {
const tickCount = useGameStore((s) => s.meta.tickCount); const tickCount = useGameStore((s) => s.meta.tickCount);
const companyName = useGameStore((s) => s.meta.companyName); const companyName = useGameStore((s) => s.meta.companyName);
const lastSaveTick = useRef(0); const lastAttemptTime = useRef(Date.now());
const failureCount = useRef(0);
useEffect(() => { useEffect(() => {
if (!companyName) return; if (!companyName) return;
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS) return;
const token = getAuthToken(); const token = getAuthToken();
if (!token) return; if (!token || !isRegistered()) return;
if (failureCount.current >= MAX_CONSECUTIVE_FAILURES) return; const now = Date.now();
const { failureCount, lastSaveTime } = useCloudSaveStore.getState();
lastSaveTick.current = tickCount; const backoffMs = failureCount > 0
? Math.min(BASE_BACKOFF_MS * Math.pow(2, failureCount - 1), MAX_BACKOFF_MS)
: CLOUD_SAVE_INTERVAL_MS;
const state = useGameStore.getState(); const timeSinceLastAttempt = now - lastAttemptTime.current;
const { activePage, notifications, infraNav, modelsTab, ...gameState } = state; if (timeSinceLastAttempt < backoffMs) return;
api.saves.put({ lastAttemptTime.current = now;
companyName: state.meta.companyName, performCloudSave();
saveVersion: state.meta.saveVersion,
gameData: gameState,
tickCount: state.meta.tickCount,
era: state.meta.currentEra,
}).then(() => {
failureCount.current = 0;
}).catch(() => {
failureCount.current++;
if (failureCount.current === MAX_CONSECUTIVE_FAILURES) {
useGameStore.getState().addNotification({
title: 'Cloud Save Failed',
message: 'Unable to save to cloud. Your progress is still saved locally.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
});
}, [tickCount, companyName]); }, [tickCount, companyName]);
const forceSave = useCallback(() => performCloudSave(), []);
return { forceSave };
} }
export async function ensureAuth(): Promise<string | null> { export async function ensureAuth(): Promise<string | null> {
-3
View File
@@ -14,7 +14,6 @@ export function getAuthToken() {
export function clearAuthToken() { export function clearAuthToken() {
authToken = null; authToken = null;
localStorage.removeItem('token-empire-auth-token'); localStorage.removeItem('token-empire-auth-token');
localStorage.removeItem('token-empire-refresh-token');
} }
export interface TokenPayload { export interface TokenPayload {
@@ -91,8 +90,6 @@ async function request<T>(path: string, options: RequestInit & { timeoutMs?: num
if (!res.ok) { if (!res.ok) {
if (res.status === 401 && authToken && !AUTH_PATHS.includes(path)) { if (res.status === 401 && authToken && !AUTH_PATHS.includes(path)) {
clearAuthToken(); clearAuthToken();
localStorage.removeItem('token-empire-save');
window.location.reload();
} }
const body = await res.json().catch(() => null); const body = await res.json().catch(() => null);
throw new Error(body?.error || `HTTP ${res.status} ${res.statusText}`); throw new Error(body?.error || `HTTP ${res.status} ${res.statusText}`);
+84 -4
View File
@@ -1,8 +1,10 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { Pencil, Check, X, LogOut } from 'lucide-react'; import { Pencil, Check, X, LogOut, Cloud, Loader2 } from 'lucide-react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { ConfirmModal } from '@/components/common/ConfirmModal'; import { ConfirmModal } from '@/components/common/ConfirmModal';
import { CloudSaveList } from '@/components/game/CloudSaveList';
import { api, setAuthToken, getTokenPayload, isRegistered, isAdmin, clearAuthToken } from '@/lib/api'; import { api, setAuthToken, getTokenPayload, isRegistered, isAdmin, clearAuthToken } from '@/lib/api';
import { performCloudSave, useCloudSaveStore } from '@/hooks/useCloudSave';
export function SettingsPage() { export function SettingsPage() {
const settings = useGameStore((s) => s.meta.settings); const settings = useGameStore((s) => s.meta.settings);
@@ -75,6 +77,10 @@ export function SettingsPage() {
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, musicVolume: v } } }); updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, musicVolume: v } } });
}; };
const setSfxVolume = (v: number) => {
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, sfxVolume: v } } });
};
const handleReset = () => { const handleReset = () => {
localStorage.removeItem('token-empire-save'); localStorage.removeItem('token-empire-save');
window.location.reload(); window.location.reload();
@@ -232,7 +238,26 @@ export function SettingsPage() {
<ToggleSwitch checked={settings.soundEnabled} onChange={toggleSound} /> <ToggleSwitch checked={settings.soundEnabled} onChange={toggleSound} />
</div> </div>
<div className="flex items-center justify-between"> <div className={`flex items-center justify-between ${!settings.soundEnabled ? 'opacity-40' : ''}`}>
<div>
<div className="text-sm">SFX Volume</div>
<div className="text-xs text-surface-400">Sound effects level</div>
</div>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={100}
value={(settings.sfxVolume ?? 0.5) * 100}
onChange={(e) => setSfxVolume(Number(e.target.value) / 100)}
disabled={!settings.soundEnabled}
className="w-32 accent-accent"
/>
<span className="text-sm font-mono text-surface-400 w-8 text-right">{Math.round((settings.sfxVolume ?? 0.5) * 100)}%</span>
</div>
</div>
<div className={`flex items-center justify-between ${!settings.soundEnabled ? 'opacity-40' : ''}`}>
<div> <div>
<div className="text-sm">Music Volume</div> <div className="text-sm">Music Volume</div>
<div className="text-xs text-surface-400">Background music level</div> <div className="text-xs text-surface-400">Background music level</div>
@@ -244,6 +269,7 @@ export function SettingsPage() {
max={100} max={100}
value={settings.musicVolume * 100} value={settings.musicVolume * 100}
onChange={(e) => setMusicVolume(Number(e.target.value) / 100)} onChange={(e) => setMusicVolume(Number(e.target.value) / 100)}
disabled={!settings.soundEnabled}
className="w-32 accent-accent" className="w-32 accent-accent"
/> />
<span className="text-sm font-mono text-surface-400 w-8 text-right">{Math.round(settings.musicVolume * 100)}%</span> <span className="text-sm font-mono text-surface-400 w-8 text-right">{Math.round(settings.musicVolume * 100)}%</span>
@@ -253,7 +279,8 @@ export function SettingsPage() {
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4"> <div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Save Data</h3> <h3 className="font-semibold">Save Data</h3>
<div className="flex gap-3"> <div className="flex flex-wrap gap-3">
{registered && <SaveToCloudButton />}
<button <button
onClick={handleExport} onClick={handleExport}
className="px-4 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm" className="px-4 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm"
@@ -282,6 +309,13 @@ export function SettingsPage() {
</div> </div>
</div> </div>
{registered && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Cloud Saves</h3>
<CloudSaveList />
</div>
)}
{showResetConfirm && ( {showResetConfirm && (
<ConfirmModal <ConfirmModal
title="Reset All Progress" title="Reset All Progress"
@@ -312,9 +346,24 @@ export function SettingsPage() {
confirmLabel={registered ? 'Log Out' : 'Sign Out'} confirmLabel={registered ? 'Log Out' : 'Sign Out'}
danger={!registered} danger={!registered}
onConfirm={async () => { onConfirm={async () => {
if (registered) {
try {
const state = useGameStore.getState();
const { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
await api.saves.put({
companyName: state.meta.companyName,
saveVersion: state.meta.saveVersion,
gameData: gameState,
tickCount: state.meta.tickCount,
era: state.meta.currentEra,
});
} catch {}
}
try { await api.auth.logout(); } catch {} try { await api.auth.logout(); } catch {}
clearAuthToken(); clearAuthToken();
localStorage.removeItem('token-empire-save'); if (!registered) {
localStorage.removeItem('token-empire-save');
}
window.location.reload(); window.location.reload();
}} }}
onCancel={() => setShowLogoutConfirm(false)} onCancel={() => setShowLogoutConfirm(false)}
@@ -324,6 +373,37 @@ export function SettingsPage() {
); );
} }
function SaveToCloudButton() {
const status = useCloudSaveStore((s) => s.status);
const lastSaveTime = useCloudSaveStore((s) => s.lastSaveTime);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
await performCloudSave();
setSaving(false);
};
const isBusy = saving || status === 'saving';
const timeLabel = lastSaveTime
? `Last saved ${Math.floor((Date.now() - lastSaveTime) / 60000)}m ago`
: null;
return (
<div className="flex items-center gap-2">
<button
onClick={handleSave}
disabled={isBusy}
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-accent/20 hover:bg-accent/30 border border-accent/50 text-accent text-sm disabled:opacity-50 transition-colors"
>
{isBusy ? <Loader2 size={14} className="animate-spin" /> : <Cloud size={14} />}
Save to Cloud
</button>
{timeLabel && <span className="text-xs text-surface-500">{timeLabel}</span>}
</div>
);
}
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: () => void }) { function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: () => void }) {
return ( return (
<button <button
+12 -36
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { triggerNotificationSound } from '@/audio/sounds';
import type { import type {
GameState, Era, GameSpeed, GameSettings, GameState, Era, GameSpeed, GameSettings,
EconomyState, InfrastructureState, ComputeState, EconomyState, InfrastructureState, ComputeState,
@@ -45,37 +46,9 @@ import {
TECH_TREE, onModelDeployed, TECH_TREE, onModelDeployed,
} from '@token-empire/game-engine'; } from '@token-empire/game-engine';
import { INITIAL_RIVALS } from '@token-empire/game-engine'; import { INITIAL_RIVALS } from '@token-empire/game-engine';
import type { ActivePage, InfraNav, ModelsTab, UIState, GameNotification } from './types';
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models' export type { ActivePage, InfraNavLevel, InfraNav, ModelsTab, UIState, GameNotification } from './types';
| 'market' | 'serving' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'invitations' | 'settings';
export type InfraNavLevel = 'clusters' | 'cluster' | 'campus' | 'datacenter';
export interface InfraNav {
level: InfraNavLevel;
clusterId?: string;
campusId?: string;
datacenterId?: string;
}
type ModelsTab = 'overview' | 'train' | 'models' | 'products';
interface UIState {
activePage: ActivePage;
notifications: GameNotification[];
infraNav: InfraNav;
modelsTab: ModelsTab;
}
export interface GameNotification {
id: string;
title: string;
message: string;
type: 'info' | 'success' | 'warning' | 'danger';
tick: number;
read: boolean;
action?: { label: string; page?: ActivePage; modelsTab?: ModelsTab };
}
function emptyDC(): Pick<DataCenter, 'networkSummary' | 'effectiveComputeRacks' | 'usedSlots' | 'usedPowerKW' | 'energyCostPerTick' | 'maintenanceCostPerTick' | 'currentUptime'> { function emptyDC(): Pick<DataCenter, 'networkSummary' | 'effectiveComputeRacks' | 'usedSlots' | 'usedPowerKW' | 'energyCostPerTick' | 'maintenanceCostPerTick' | 'currentUptime'> {
return { return {
@@ -313,12 +286,15 @@ export const useGameStore = create<Store>()(
setModelsTab: (tab) => set({ modelsTab: tab }), setModelsTab: (tab) => set({ modelsTab: tab }),
addNotification: (n) => set((s) => ({ addNotification: (n) => {
notifications: [ set((s) => ({
{ ...n, id: uuid(), read: false }, notifications: [
...s.notifications.slice(0, 49), { ...n, id: uuid(), read: false },
], ...s.notifications.slice(0, 49),
})), ],
}));
triggerNotificationSound(n);
},
dismissNotification: (id) => set((s) => ({ dismissNotification: (id) => set((s) => ({
notifications: s.notifications.map(n => notifications: s.notifications.map(n =>
+30
View File
@@ -0,0 +1,30 @@
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
| 'market' | 'serving' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'invitations' | 'settings';
export type InfraNavLevel = 'clusters' | 'cluster' | 'campus' | 'datacenter';
export interface InfraNav {
level: InfraNavLevel;
clusterId?: string;
campusId?: string;
datacenterId?: string;
}
export type ModelsTab = 'overview' | 'train' | 'models' | 'products';
export interface UIState {
activePage: ActivePage;
notifications: GameNotification[];
infraNav: InfraNav;
modelsTab: ModelsTab;
}
export interface GameNotification {
id: string;
title: string;
message: string;
type: 'info' | 'success' | 'warning' | 'danger';
tick: number;
read: boolean;
action?: { label: string; page?: ActivePage; modelsTab?: ModelsTab };
}
+18
View File
@@ -0,0 +1,18 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ['**/dist/**', '**/node_modules/**', '**/*.js', '**/*.mjs', '**/drizzle/**'],
},
{
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'no-empty': ['error', { allowEmptyCatch: true }],
'preserve-caught-error': 'off',
},
},
);
+4 -1
View File
@@ -5,7 +5,7 @@
"dev": "turbo dev", "dev": "turbo dev",
"build": "turbo build", "build": "turbo build",
"typecheck": "turbo typecheck", "typecheck": "turbo typecheck",
"lint": "turbo lint", "lint": "eslint .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"clean": "turbo clean", "clean": "turbo clean",
@@ -13,8 +13,11 @@
"simulate:ci": "pnpm --filter @token-empire/game-simulation simulate:ci" "simulate:ci": "pnpm --filter @token-empire/game-simulation simulate:ci"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.2.1",
"turbo": "^2.5.0", "turbo": "^2.5.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"typescript-eslint": "^8.59.1",
"vitest": "^4.1.5" "vitest": "^4.1.5"
}, },
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",
@@ -75,7 +75,7 @@ interface CachedSlot {
} }
let cachedDeploymentVersion = -1; let cachedDeploymentVersion = -1;
let cachedSlots: CachedSlot[] = []; const cachedSlots: CachedSlot[] = [];
const fleetOutput: ModelServingSlot[] = []; const fleetOutput: ModelServingSlot[] = [];
const mainRemaining = new Map<string, number>(); const mainRemaining = new Map<string, number>();
@@ -83,7 +83,7 @@ const mainUsed = new Map<string, number>();
const entRemaining = new Map<string, number>(); const entRemaining = new Map<string, number>();
const entUsed = new Map<string, number>(); const entUsed = new Map<string, number>();
let cachedUtilization: ModelUtilizationEntry[] = []; const cachedUtilization: ModelUtilizationEntry[] = [];
export function resetFleetCache(): void { export function resetFleetCache(): void {
cachedDeploymentVersion = -1; cachedDeploymentVersion = -1;
@@ -15,7 +15,7 @@ export interface ReputationTickResult {
} }
export function processReputation(state: GameState, researchBonuses?: ResearchBonuses): ReputationState & { _safetyIncident?: boolean } { export function processReputation(state: GameState, researchBonuses?: ResearchBonuses): ReputationState & { _safetyIncident?: boolean } {
let { safetyRecord, publicPerception, employeeSatisfaction, regulatoryStanding } = state.reputation; let { safetyRecord, publicPerception } = state.reputation;
let safetyIncident = false; let safetyIncident = false;
if (state.models.bestDeployedSafetyScore > 0) { if (state.models.bestDeployedSafetyScore > 0) {
@@ -39,13 +39,13 @@ export function processReputation(state: GameState, researchBonuses?: ResearchBo
const safetyResearchCount = state.research.completedResearch const safetyResearchCount = state.research.completedResearch
.filter(r => r.includes('alignment') || r.includes('interpretability') || r.includes('constitutional')).length; .filter(r => r.includes('alignment') || r.includes('interpretability') || r.includes('constitutional')).length;
const complianceBonus = safetyResearchCount * 8; const complianceBonus = safetyResearchCount * 8;
regulatoryStanding = Math.min(100, Math.max(0, const regulatoryStanding = Math.min(100, Math.max(0,
50 + complianceBonus - regulatoryPressure, 50 + complianceBonus - regulatoryPressure,
)); ));
const talentMorale = Object.values(state.talent.departments) const talentMorale = Object.values(state.talent.departments)
.reduce((sum, d) => sum + d.morale, 0) / 4; .reduce((sum, d) => sum + d.morale, 0) / 4;
employeeSatisfaction = talentMorale * 100; const employeeSatisfaction = talentMorale * 100;
const reputationResearchBonus = researchBonuses?.reputationBonus ?? 0; const reputationResearchBonus = researchBonuses?.reputationBonus ?? 0;
publicPerception = Math.min(100, publicPerception + reputationResearchBonus * PUBLIC_PERCEPTION_GROWTH_RATE); publicPerception = Math.min(100, publicPerception + reputationResearchBonus * PUBLIC_PERCEPTION_GROWTH_RATE);
@@ -55,8 +55,8 @@ export function runSanityChecks(metrics: SimulationMetrics[]): SanityCheckResult
const key = 'reputation-scale-consistency'; const key = 'reputation-scale-consistency';
if (!seen.has(key)) { if (!seen.has(key)) {
seen.add(key); seen.add(key);
const lowName = ['safetyRecord', 'publicPerception', 'employeeSatisfaction', 'regulatoryStanding'] const reputationFields = ['safetyRecord', 'publicPerception', 'employeeSatisfaction', 'regulatoryStanding'];
[components.indexOf(belowThreshold[0])]; const lowName = reputationFields[components.indexOf(belowThreshold[0])];
violations.push({ violations.push({
tick: m.tick, check: key, tick: m.tick, check: key,
message: `${lowName} = ${belowThreshold[0].toFixed(2)} while others are 10+. Likely a scale mismatch (0-1 vs 0-100)`, message: `${lowName} = ${belowThreshold[0].toFixed(2)} while others are 10+. Likely a scale mismatch (0-1 vs 0-100)`,
+1
View File
@@ -1,6 +1,7 @@
{ {
"extends": "@token-empire/tsconfig/node.json", "extends": "@token-empire/tsconfig/node.json",
"compilerOptions": { "compilerOptions": {
"lib": ["ES2022", "DOM"],
"outDir": "dist", "outDir": "dist",
"rootDir": "src" "rootDir": "src"
}, },
+2
View File
@@ -45,11 +45,13 @@ export type GameSpeed = 1 | 2 | 5;
export interface GameSettings { export interface GameSettings {
soundEnabled: boolean; soundEnabled: boolean;
musicVolume: number; musicVolume: number;
sfxVolume: number;
} }
export const INITIAL_SETTINGS: GameSettings = { export const INITIAL_SETTINGS: GameSettings = {
soundEnabled: true, soundEnabled: true,
musicVolume: 0.5, musicVolume: 0.5,
sfxVolume: 0.5,
}; };
export const SAVE_VERSION = 10; export const SAVE_VERSION = 10;
+706
View File
File diff suppressed because it is too large Load Diff