Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea3951aa0c | |||
| 6ea136083a | |||
| 8ef1226755 | |||
| f3e6a2e692 | |||
| d609934b73 | |||
| 1e3d50719e | |||
| 5d30d1f4a1 | |||
| ce2cab3404 | |||
| 035d4f0385 | |||
| 5e4007160c | |||
| eca244f9d4 | |||
| 95b43dceec | |||
| 01d9703aec | |||
| 8e5dca471e | |||
| cc27c00991 | |||
| c0965cb7d7 | |||
| 5f7b728463 | |||
| b0c552562a |
@@ -10,3 +10,5 @@ balance-report*.json
|
||||
balance-metrics*.csv
|
||||
multirun-summary.csv
|
||||
multirun-timeseries.csv
|
||||
.planning/
|
||||
*.log
|
||||
|
||||
@@ -7,6 +7,9 @@ import { createToken } from '../lib/jwt';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import type { AppEnv } from '../types';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
const auth = new Hono<AppEnv>();
|
||||
|
||||
auth.post('/anonymous', async (c) => {
|
||||
@@ -27,11 +30,11 @@ auth.post('/register', authMiddleware, async (c) => {
|
||||
inviteCode: string;
|
||||
}>();
|
||||
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
if (!email || !EMAIL_REGEX.test(email)) {
|
||||
return c.json({ error: 'Valid email required' }, 400);
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
||||
if (!password || password.length < MIN_PASSWORD_LENGTH) {
|
||||
return c.json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` }, 400);
|
||||
}
|
||||
|
||||
if (process.env.REQUIRE_INVITE !== 'false') {
|
||||
@@ -117,8 +120,8 @@ auth.post('/change-password', authMiddleware, async (c) => {
|
||||
newPassword: string;
|
||||
}>();
|
||||
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return c.json({ error: 'New password must be at least 8 characters' }, 400);
|
||||
if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||
return c.json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` }, 400);
|
||||
}
|
||||
|
||||
if (!user.mustResetPassword) {
|
||||
@@ -193,7 +196,7 @@ auth.post('/change-email', authMiddleware, async (c) => {
|
||||
currentPassword: string;
|
||||
}>();
|
||||
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
if (!email || !EMAIL_REGEX.test(email)) {
|
||||
return c.json({ error: 'Valid email required' }, 400);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Hono } from 'hono';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { eq, and, desc, notInArray } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { saves } from '../db/schema';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import type { AppEnv } from '../types';
|
||||
|
||||
const MAX_SAVES_PER_USER = 10;
|
||||
|
||||
const savesRouter = new Hono<AppEnv>();
|
||||
|
||||
savesRouter.use('*', authMiddleware);
|
||||
@@ -23,7 +25,7 @@ savesRouter.get('/', async (c) => {
|
||||
.from(saves)
|
||||
.where(eq(saves.userId, userId))
|
||||
.orderBy(desc(saves.updatedAt))
|
||||
.limit(10);
|
||||
.limit(MAX_SAVES_PER_USER);
|
||||
|
||||
return c.json({ saves: userSaves });
|
||||
});
|
||||
@@ -68,29 +70,6 @@ savesRouter.put('/', async (c) => {
|
||||
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
|
||||
.insert(saves)
|
||||
.values({
|
||||
@@ -103,6 +82,20 @@ savesRouter.put('/', async (c) => {
|
||||
})
|
||||
.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 });
|
||||
});
|
||||
|
||||
|
||||
+17
-2
@@ -4,6 +4,7 @@ import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { NewGameScreen } from '@/components/game/NewGameScreen';
|
||||
import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
|
||||
import { InviteGateScreen } from '@/components/game/InviteGateScreen';
|
||||
import { SaveConflictDialog } from '@/components/game/SaveConflictDialog';
|
||||
import { useGameLoop } from '@/hooks/useGameLoop';
|
||||
import { useAuthGate } from '@/hooks/useAuthGate';
|
||||
import { useCloudSave } from '@/hooks/useCloudSave';
|
||||
@@ -54,8 +55,10 @@ function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () =>
|
||||
}
|
||||
|
||||
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 currentEra = useGameStore((s) => s.meta.currentEra);
|
||||
const tickCount = useGameStore((s) => s.meta.tickCount);
|
||||
const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp);
|
||||
const [catchUpTicks, setCatchUpTicks] = useState<number | null>(null);
|
||||
const [catchUpDone, setCatchUpDone] = useState(false);
|
||||
@@ -71,7 +74,7 @@ export function App() {
|
||||
}
|
||||
}, [companyName, lastTickTimestamp, catchUpDone]);
|
||||
|
||||
useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset);
|
||||
useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset || hasConflict);
|
||||
useCloudSave();
|
||||
|
||||
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) {
|
||||
return <NewGameScreen cloudSave={cloudSave} onContinue={loadCloudSave} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -18,16 +18,13 @@ export function DevMenu() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('resources');
|
||||
|
||||
const isEnabled = import.meta.env.DEV || localStorage.getItem('token-empire-dev-menu') === 'true';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
const handler = () => setIsOpen((o) => !o);
|
||||
window.addEventListener('toggle-dev-menu', handler);
|
||||
return () => window.removeEventListener('toggle-dev-menu', handler);
|
||||
}, [isEnabled]);
|
||||
}, []);
|
||||
|
||||
if (!isEnabled || !isOpen) return null;
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 w-[440px] max-h-[520px] flex flex-col bg-surface-900 border border-surface-700 rounded-lg shadow-2xl">
|
||||
|
||||
@@ -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} · {formatDuration(save.tickCount)} · {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 { TopBar } from './TopBar';
|
||||
import { ToastContainer } from '@/components/common/ToastContainer';
|
||||
@@ -5,6 +6,7 @@ import { DevMenu } from '@/components/dev/DevMenu';
|
||||
import { useGameStore } from '@/store';
|
||||
import { useHashRouter } from '@/hooks/useHashRouter';
|
||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||
import { initAudioSystem } from '@/audio';
|
||||
import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { InfrastructurePage } from '@/pages/InfrastructurePage';
|
||||
import { ResearchPage } from '@/pages/ResearchPage';
|
||||
@@ -23,6 +25,7 @@ import { InvitationsPage } from '@/pages/InvitationsPage';
|
||||
export function MainLayout() {
|
||||
const { subPath, setSubPath } = useHashRouter();
|
||||
useKeyboardShortcuts();
|
||||
useEffect(() => initAudioSystem(), []);
|
||||
const activePage = useGameStore((s) => s.activePage);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { Pause, Play, Bell, Share2 } from 'lucide-react';
|
||||
import { CompanyStatsCard } from '@/components/game/CompanyStatsCard';
|
||||
import { CloudSaveIndicator } from '@/components/game/CloudSaveIndicator';
|
||||
import { NotificationPanel } from '@/components/common/NotificationPanel';
|
||||
import { useGameStore } from '@/store';
|
||||
import { isRegistered } from '@/lib/api';
|
||||
import { performCloudSave } from '@/hooks/useCloudSave';
|
||||
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@token-empire/shared';
|
||||
import type { GameSpeed } from '@token-empire/shared';
|
||||
import { Tooltip } from '@/components/common/Tooltip';
|
||||
@@ -97,6 +100,10 @@ export function TopBar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isRegistered() && (
|
||||
<CloudSaveIndicator onForceSave={performCloudSave} />
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowStats(true)}
|
||||
className="p-2 rounded hover:bg-surface-800 transition-colors"
|
||||
|
||||
@@ -20,7 +20,9 @@ interface AuthGateState {
|
||||
isAdmin: boolean;
|
||||
config: { requireInvite: boolean; userInvitations: number } | null;
|
||||
cloudSave: CloudSaveInfo | null;
|
||||
hasConflict: boolean;
|
||||
loadCloudSave: () => Promise<void>;
|
||||
resolveConflict: (choice: 'local' | 'cloud' | 'new') => Promise<void>;
|
||||
setRegistered: (value: boolean) => void;
|
||||
setNeedsPasswordReset: (value: boolean) => void;
|
||||
retry: () => void;
|
||||
@@ -34,7 +36,7 @@ export function useAuthGate(): AuthGateState {
|
||||
const [passwordReset, setPasswordReset] = useState(false);
|
||||
const [admin, setAdmin] = useState(false);
|
||||
const [cloudSave, setCloudSave] = useState<CloudSaveInfo | null>(null);
|
||||
const [initCount, setInitCount] = useState(0);
|
||||
const [hasConflict, setHasConflict] = useState(false);
|
||||
|
||||
const init = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -95,7 +97,6 @@ export function useAuthGate(): AuthGateState {
|
||||
useState(() => { init(); });
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setInitCount(c => c + 1);
|
||||
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);
|
||||
const payload = getTokenPayload();
|
||||
if (payload) {
|
||||
setAdmin(payload.role === 'admin');
|
||||
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) => {
|
||||
@@ -135,7 +173,9 @@ export function useAuthGate(): AuthGateState {
|
||||
isAdmin: admin,
|
||||
config,
|
||||
cloudSave,
|
||||
hasConflict,
|
||||
loadCloudSave,
|
||||
resolveConflict,
|
||||
setRegistered: handleSetRegistered,
|
||||
setNeedsPasswordReset: handleSetPasswordReset,
|
||||
retry,
|
||||
|
||||
@@ -1,50 +1,118 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { useGameStore } from '@/store';
|
||||
import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload } from '@/lib/api';
|
||||
import { AUTO_SAVE_INTERVAL_TICKS } from '@token-empire/shared';
|
||||
import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload, isRegistered } from '@/lib/api';
|
||||
|
||||
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() {
|
||||
const tickCount = useGameStore((s) => s.meta.tickCount);
|
||||
const companyName = useGameStore((s) => s.meta.companyName);
|
||||
const lastSaveTick = useRef(0);
|
||||
const failureCount = useRef(0);
|
||||
const lastAttemptTime = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyName) return;
|
||||
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS) return;
|
||||
|
||||
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 { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
|
||||
const timeSinceLastAttempt = now - lastAttemptTime.current;
|
||||
if (timeSinceLastAttempt < backoffMs) return;
|
||||
|
||||
api.saves.put({
|
||||
companyName: state.meta.companyName,
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
lastAttemptTime.current = now;
|
||||
performCloudSave();
|
||||
}, [tickCount, companyName]);
|
||||
|
||||
const forceSave = useCallback(() => performCloudSave(), []);
|
||||
|
||||
return { forceSave };
|
||||
}
|
||||
|
||||
export async function ensureAuth(): Promise<string | null> {
|
||||
|
||||
@@ -14,7 +14,6 @@ export function getAuthToken() {
|
||||
export function clearAuthToken() {
|
||||
authToken = null;
|
||||
localStorage.removeItem('token-empire-auth-token');
|
||||
localStorage.removeItem('token-empire-refresh-token');
|
||||
}
|
||||
|
||||
export interface TokenPayload {
|
||||
@@ -91,8 +90,6 @@ async function request<T>(path: string, options: RequestInit & { timeoutMs?: num
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 && authToken && !AUTH_PATHS.includes(path)) {
|
||||
clearAuthToken();
|
||||
localStorage.removeItem('token-empire-save');
|
||||
window.location.reload();
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.error || `HTTP ${res.status} ${res.statusText}`);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { ConfirmModal } from '@/components/common/ConfirmModal';
|
||||
import { CloudSaveList } from '@/components/game/CloudSaveList';
|
||||
import { api, setAuthToken, getTokenPayload, isRegistered, isAdmin, clearAuthToken } from '@/lib/api';
|
||||
import { performCloudSave, useCloudSaveStore } from '@/hooks/useCloudSave';
|
||||
|
||||
export function SettingsPage() {
|
||||
const settings = useGameStore((s) => s.meta.settings);
|
||||
@@ -75,6 +77,10 @@ export function SettingsPage() {
|
||||
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, musicVolume: v } } });
|
||||
};
|
||||
|
||||
const setSfxVolume = (v: number) => {
|
||||
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, sfxVolume: v } } });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
localStorage.removeItem('token-empire-save');
|
||||
window.location.reload();
|
||||
@@ -232,7 +238,26 @@ export function SettingsPage() {
|
||||
<ToggleSwitch checked={settings.soundEnabled} onChange={toggleSound} />
|
||||
</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 className="text-sm">Music Volume</div>
|
||||
<div className="text-xs text-surface-400">Background music level</div>
|
||||
@@ -244,6 +269,7 @@ export function SettingsPage() {
|
||||
max={100}
|
||||
value={settings.musicVolume * 100}
|
||||
onChange={(e) => setMusicVolume(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.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">
|
||||
<h3 className="font-semibold">Save Data</h3>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{registered && <SaveToCloudButton />}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
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>
|
||||
|
||||
{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 && (
|
||||
<ConfirmModal
|
||||
title="Reset All Progress"
|
||||
@@ -312,9 +346,24 @@ export function SettingsPage() {
|
||||
confirmLabel={registered ? 'Log Out' : 'Sign Out'}
|
||||
danger={!registered}
|
||||
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 {}
|
||||
clearAuthToken();
|
||||
localStorage.removeItem('token-empire-save');
|
||||
if (!registered) {
|
||||
localStorage.removeItem('token-empire-save');
|
||||
}
|
||||
window.location.reload();
|
||||
}}
|
||||
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 }) {
|
||||
return (
|
||||
<button
|
||||
|
||||
+12
-36
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { triggerNotificationSound } from '@/audio/sounds';
|
||||
import type {
|
||||
GameState, Era, GameSpeed, GameSettings,
|
||||
EconomyState, InfrastructureState, ComputeState,
|
||||
@@ -45,37 +46,9 @@ import {
|
||||
TECH_TREE, onModelDeployed,
|
||||
} 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'
|
||||
| '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 };
|
||||
}
|
||||
export type { ActivePage, InfraNavLevel, InfraNav, ModelsTab, UIState, GameNotification } from './types';
|
||||
|
||||
function emptyDC(): Pick<DataCenter, 'networkSummary' | 'effectiveComputeRacks' | 'usedSlots' | 'usedPowerKW' | 'energyCostPerTick' | 'maintenanceCostPerTick' | 'currentUptime'> {
|
||||
return {
|
||||
@@ -313,12 +286,15 @@ export const useGameStore = create<Store>()(
|
||||
|
||||
setModelsTab: (tab) => set({ modelsTab: tab }),
|
||||
|
||||
addNotification: (n) => set((s) => ({
|
||||
notifications: [
|
||||
{ ...n, id: uuid(), read: false },
|
||||
...s.notifications.slice(0, 49),
|
||||
],
|
||||
})),
|
||||
addNotification: (n) => {
|
||||
set((s) => ({
|
||||
notifications: [
|
||||
{ ...n, id: uuid(), read: false },
|
||||
...s.notifications.slice(0, 49),
|
||||
],
|
||||
}));
|
||||
triggerNotificationSound(n);
|
||||
},
|
||||
|
||||
dismissNotification: (id) => set((s) => ({
|
||||
notifications: s.notifications.map(n =>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -5,7 +5,7 @@
|
||||
"dev": "turbo dev",
|
||||
"build": "turbo build",
|
||||
"typecheck": "turbo typecheck",
|
||||
"lint": "turbo lint",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"clean": "turbo clean",
|
||||
@@ -13,8 +13,11 @@
|
||||
"simulate:ci": "pnpm --filter @token-empire/game-simulation simulate:ci"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"turbo": "^2.5.0",
|
||||
"typescript": "^5.8.0",
|
||||
"typescript-eslint": "^8.59.1",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -75,7 +75,7 @@ interface CachedSlot {
|
||||
}
|
||||
|
||||
let cachedDeploymentVersion = -1;
|
||||
let cachedSlots: CachedSlot[] = [];
|
||||
const cachedSlots: CachedSlot[] = [];
|
||||
const fleetOutput: ModelServingSlot[] = [];
|
||||
|
||||
const mainRemaining = new Map<string, number>();
|
||||
@@ -83,7 +83,7 @@ const mainUsed = new Map<string, number>();
|
||||
const entRemaining = new Map<string, number>();
|
||||
const entUsed = new Map<string, number>();
|
||||
|
||||
let cachedUtilization: ModelUtilizationEntry[] = [];
|
||||
const cachedUtilization: ModelUtilizationEntry[] = [];
|
||||
|
||||
export function resetFleetCache(): void {
|
||||
cachedDeploymentVersion = -1;
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ReputationTickResult {
|
||||
}
|
||||
|
||||
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;
|
||||
if (state.models.bestDeployedSafetyScore > 0) {
|
||||
@@ -39,13 +39,13 @@ export function processReputation(state: GameState, researchBonuses?: ResearchBo
|
||||
const safetyResearchCount = state.research.completedResearch
|
||||
.filter(r => r.includes('alignment') || r.includes('interpretability') || r.includes('constitutional')).length;
|
||||
const complianceBonus = safetyResearchCount * 8;
|
||||
regulatoryStanding = Math.min(100, Math.max(0,
|
||||
const regulatoryStanding = Math.min(100, Math.max(0,
|
||||
50 + complianceBonus - regulatoryPressure,
|
||||
));
|
||||
|
||||
const talentMorale = Object.values(state.talent.departments)
|
||||
.reduce((sum, d) => sum + d.morale, 0) / 4;
|
||||
employeeSatisfaction = talentMorale * 100;
|
||||
const employeeSatisfaction = talentMorale * 100;
|
||||
|
||||
const reputationResearchBonus = researchBonuses?.reputationBonus ?? 0;
|
||||
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';
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
const lowName = ['safetyRecord', 'publicPerception', 'employeeSatisfaction', 'regulatoryStanding']
|
||||
[components.indexOf(belowThreshold[0])];
|
||||
const reputationFields = ['safetyRecord', 'publicPerception', 'employeeSatisfaction', 'regulatoryStanding'];
|
||||
const lowName = reputationFields[components.indexOf(belowThreshold[0])];
|
||||
violations.push({
|
||||
tick: m.tick, check: key,
|
||||
message: `${lowName} = ${belowThreshold[0].toFixed(2)} while others are 10+. Likely a scale mismatch (0-1 vs 0-100)`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "@token-empire/tsconfig/node.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
|
||||
@@ -45,11 +45,13 @@ export type GameSpeed = 1 | 2 | 5;
|
||||
export interface GameSettings {
|
||||
soundEnabled: boolean;
|
||||
musicVolume: number;
|
||||
sfxVolume: number;
|
||||
}
|
||||
|
||||
export const INITIAL_SETTINGS: GameSettings = {
|
||||
soundEnabled: true,
|
||||
musicVolume: 0.5,
|
||||
sfxVolume: 0.5,
|
||||
};
|
||||
|
||||
export const SAVE_VERSION = 10;
|
||||
|
||||
Generated
+706
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user