feat: bootstrap walking skeleton
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Zustand bridge: engine snapshots -> view (~10 fps). Built in T3.4.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildContent } from '../../content/schema';
|
||||
import { createGameState, startAction } from '../../engine/game';
|
||||
import { createSave, serializeSave } from '../../engine/save';
|
||||
import { createMemoryBackend, loadGame, saveGame } from '../persistence';
|
||||
|
||||
function testContent() {
|
||||
return buildContent({
|
||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
||||
actions: [
|
||||
{ id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe('loadGame()', () => {
|
||||
it('returns a fresh game when no save exists', async () => {
|
||||
const content = testContent();
|
||||
const result = await loadGame(content, createMemoryBackend(), 1000);
|
||||
expect(result.state.resources.gold).toBe(0);
|
||||
expect(result.offlineMs).toBe(0);
|
||||
});
|
||||
|
||||
it('round-trips a saved game and credits offline progress', async () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
state.resources.gold = 10;
|
||||
startAction(state, content, 'forage');
|
||||
const backend = createMemoryBackend();
|
||||
await saveGame(state, backend, 1000);
|
||||
|
||||
const result = await loadGame(content, backend, 1000 + 9000); // 9s offline
|
||||
expect(result.offlineMs).toBe(9000);
|
||||
expect(result.state.resources.gold).toBe(13); // 10 + 9000/3000
|
||||
expect(result.state.activeActionId).toBe('forage');
|
||||
});
|
||||
|
||||
it('falls back to a fresh game on a corrupt save instead of throwing', async () => {
|
||||
const content = testContent();
|
||||
const result = await loadGame(content, createMemoryBackend('@@@garbage@@@'), 1000);
|
||||
expect(result.state.resources.gold).toBe(0);
|
||||
expect(result.offlineMs).toBe(0);
|
||||
});
|
||||
|
||||
it('drops an active action that no longer exists in content', async () => {
|
||||
const content = testContent();
|
||||
const stale = serializeSave(
|
||||
createSave(
|
||||
{ resources: { gold: 1 }, activeActionId: 'ghost-action', actionElapsedMs: 0 },
|
||||
1000,
|
||||
),
|
||||
);
|
||||
const result = await loadGame(content, createMemoryBackend(stale), 1000);
|
||||
expect(result.state.activeActionId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildContent } from '../../content/schema';
|
||||
import { createGameState, startAction } from '../../engine/game';
|
||||
import { formatOfflineDuration, toView } from '../viewModel';
|
||||
|
||||
function testContent() {
|
||||
return buildContent({
|
||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
|
||||
actions: [
|
||||
{ id: 'forage', name: 'Forage', durationMs: 200, yields: { resourceId: 'gold', amount: 1 } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe('toView()', () => {
|
||||
it('maps resources with their names and amounts', () => {
|
||||
const content = testContent();
|
||||
const view = toView(createGameState(content), content);
|
||||
expect(view.resources).toEqual([{ id: 'gold', name: 'Gold', amount: 4 }]);
|
||||
});
|
||||
|
||||
it('reports no progress and no action name when idle', () => {
|
||||
const content = testContent();
|
||||
const view = toView(createGameState(content), content);
|
||||
expect(view.activeActionId).toBeNull();
|
||||
expect(view.actionName).toBeNull();
|
||||
expect(view.actionProgress).toBe(0);
|
||||
});
|
||||
|
||||
it('reports the active action name and fractional progress', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
startAction(state, content, 'forage');
|
||||
state.actionElapsedMs = 50; // of 200ms
|
||||
const view = toView(state, content);
|
||||
expect(view.actionName).toBe('Forage');
|
||||
expect(view.actionProgress).toBeCloseTo(0.25);
|
||||
});
|
||||
|
||||
it('clamps progress to at most 1', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
startAction(state, content, 'forage');
|
||||
state.actionElapsedMs = 999;
|
||||
expect(toView(state, content).actionProgress).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatOfflineDuration()', () => {
|
||||
it('formats sub-minute durations in seconds', () => {
|
||||
expect(formatOfflineDuration(0)).toBe('0s');
|
||||
expect(formatOfflineDuration(45_000)).toBe('45s');
|
||||
});
|
||||
|
||||
it('formats minutes with seconds', () => {
|
||||
expect(formatOfflineDuration(90_000)).toBe('1m 30s');
|
||||
expect(formatOfflineDuration(120_000)).toBe('2m');
|
||||
});
|
||||
|
||||
it('formats hours with minutes', () => {
|
||||
expect(formatOfflineDuration(3_660_000)).toBe('1h 1m');
|
||||
expect(formatOfflineDuration(7_200_000)).toBe('2h');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { del, get, set } from 'idb-keyval';
|
||||
import type { Content } from '../content/schema';
|
||||
import { createGameState, type GameState } from '../engine/game';
|
||||
import { applyOfflineProgress, createSave, deserializeSave, serializeSave } from '../engine/save';
|
||||
|
||||
/**
|
||||
* Persistence adapter (IO layer — deliberately outside the pure engine).
|
||||
*
|
||||
* A `SaveBackend` is a pluggable string store; the orchestration (`loadGame`,
|
||||
* `saveGame`) turns it into typed game state via the engine's save module and
|
||||
* credits offline progress on boot. The real backend prefers IndexedDB
|
||||
* (idb-keyval) and falls back to localStorage, then to an in-memory store so the
|
||||
* game still runs (without persistence) in hostile environments.
|
||||
*/
|
||||
|
||||
export const SAVE_KEY = 'idlegame:save:v1';
|
||||
|
||||
export interface SaveBackend {
|
||||
load(): Promise<string | null>;
|
||||
save(serialized: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createMemoryBackend(initial: string | null = null): SaveBackend {
|
||||
let value = initial;
|
||||
return {
|
||||
load: () => Promise.resolve(value),
|
||||
save: (serialized) => {
|
||||
value = serialized;
|
||||
return Promise.resolve();
|
||||
},
|
||||
clear: () => {
|
||||
value = null;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createLocalStorageBackend(key: string): SaveBackend {
|
||||
return {
|
||||
load: () => Promise.resolve(globalThis.localStorage.getItem(key)),
|
||||
save: (serialized) => {
|
||||
globalThis.localStorage.setItem(key, serialized);
|
||||
return Promise.resolve();
|
||||
},
|
||||
clear: () => {
|
||||
globalThis.localStorage.removeItem(key);
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createIdbBackend(key: string): SaveBackend {
|
||||
return {
|
||||
load: async () => (await get<string>(key)) ?? null,
|
||||
save: (serialized) => set(key, serialized),
|
||||
clear: () => del(key),
|
||||
};
|
||||
}
|
||||
|
||||
/** Choose the best available backend for the current environment. */
|
||||
export function createDefaultBackend(key: string = SAVE_KEY): SaveBackend {
|
||||
if (typeof indexedDB !== 'undefined') {
|
||||
return createIdbBackend(key);
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return createLocalStorageBackend(key);
|
||||
}
|
||||
return createMemoryBackend();
|
||||
}
|
||||
|
||||
export interface LoadResult {
|
||||
state: GameState;
|
||||
/** Milliseconds of offline time credited on this load (0 for a fresh game). */
|
||||
offlineMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and hydrate game state, crediting offline progress. A missing or corrupt
|
||||
* save yields a fresh game rather than throwing — never block boot on bad data.
|
||||
*/
|
||||
export async function loadGame(
|
||||
content: Content,
|
||||
backend: SaveBackend,
|
||||
now: number,
|
||||
): Promise<LoadResult> {
|
||||
const raw = await backend.load();
|
||||
if (!raw) {
|
||||
return { state: createGameState(content), offlineMs: 0 };
|
||||
}
|
||||
|
||||
let savedAt: number;
|
||||
let state: GameState;
|
||||
try {
|
||||
const save = deserializeSave(raw);
|
||||
const base = createGameState(content);
|
||||
const activeActionId =
|
||||
save.state.activeActionId && content.actionsById[save.state.activeActionId]
|
||||
? save.state.activeActionId
|
||||
: null;
|
||||
state = {
|
||||
resources: { ...base.resources, ...save.state.resources },
|
||||
activeActionId,
|
||||
actionElapsedMs: save.state.actionElapsedMs,
|
||||
};
|
||||
savedAt = save.savedAt;
|
||||
} catch {
|
||||
return { state: createGameState(content), offlineMs: 0 };
|
||||
}
|
||||
|
||||
const offlineMs = applyOfflineProgress(state, content, savedAt, now);
|
||||
return { state, offlineMs };
|
||||
}
|
||||
|
||||
export async function saveGame(state: GameState, backend: SaveBackend, now: number): Promise<void> {
|
||||
await backend.save(serializeSave(createSave(state, now)));
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { content } from '../content';
|
||||
import { startAction as engineStartAction, type GameState, tickGame } from '../engine/game';
|
||||
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
|
||||
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
|
||||
import { useGameStore } from './store';
|
||||
import { formatOfflineDuration, toView } from './viewModel';
|
||||
|
||||
/**
|
||||
* The game runtime: the one place that owns the engine state and the wall clock.
|
||||
*
|
||||
* It drives the fixed-timestep tick loop off requestAnimationFrame, mirrors a
|
||||
* view snapshot into the Zustand store at ~10 fps, autosaves on an interval, and
|
||||
* persists on tab-hide / unload. Boot loads the save and credits offline time.
|
||||
* All environment coupling (RAF, Date.now, DOM events, IndexedDB) lives here so
|
||||
* the engine stays pure.
|
||||
*/
|
||||
|
||||
const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh
|
||||
const AUTOSAVE_INTERVAL_MS = 10_000;
|
||||
|
||||
class GameRuntime {
|
||||
private state: GameState | null = null;
|
||||
private readonly loop: TickLoop = createTickLoop();
|
||||
private readonly backend: SaveBackend = createDefaultBackend();
|
||||
private rafId: number | null = null;
|
||||
private lastPublishAt = 0;
|
||||
private lastSaveAt = 0;
|
||||
private booted = false;
|
||||
|
||||
async boot(): Promise<void> {
|
||||
if (this.booted) {
|
||||
return;
|
||||
}
|
||||
this.booted = true;
|
||||
|
||||
const now = Date.now();
|
||||
const { state, offlineMs } = await loadGame(content, this.backend, now);
|
||||
this.state = state;
|
||||
this.lastSaveAt = now;
|
||||
|
||||
const store = useGameStore.getState();
|
||||
store.appendLog(
|
||||
offlineMs >= 1000
|
||||
? `Welcome back — credited ${formatOfflineDuration(offlineMs)} of offline progress.`
|
||||
: 'A new tale begins. Choose an action.',
|
||||
);
|
||||
|
||||
this.publish();
|
||||
this.installLifecycleHooks();
|
||||
this.rafId = requestAnimationFrame(this.frame);
|
||||
}
|
||||
|
||||
/** Stop the loop. Used on teardown (e.g. HMR dispose); not needed in normal play. */
|
||||
stop(): void {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
startAction(actionId: string): void {
|
||||
const state = this.state;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
engineStartAction(state, content, actionId);
|
||||
const action = content.actionsById[actionId];
|
||||
if (action) {
|
||||
useGameStore.getState().appendLog(`Started: ${action.name}.`);
|
||||
}
|
||||
this.publish();
|
||||
}
|
||||
|
||||
private readonly frame = (monoNow: number): void => {
|
||||
const state = this.state;
|
||||
if (state) {
|
||||
advance(this.loop, monoNow, () => tickGame(state, content, TICK_MS));
|
||||
|
||||
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
|
||||
this.publish();
|
||||
this.lastPublishAt = monoNow;
|
||||
}
|
||||
|
||||
const wall = Date.now();
|
||||
if (wall - this.lastSaveAt >= AUTOSAVE_INTERVAL_MS) {
|
||||
void this.save(wall);
|
||||
}
|
||||
}
|
||||
this.rafId = requestAnimationFrame(this.frame);
|
||||
};
|
||||
|
||||
private publish(): void {
|
||||
const state = this.state;
|
||||
if (state) {
|
||||
useGameStore.getState().setView(toView(state, content));
|
||||
}
|
||||
}
|
||||
|
||||
private async save(now: number = Date.now()): Promise<void> {
|
||||
const state = this.state;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
this.lastSaveAt = now;
|
||||
await saveGame(state, this.backend, now);
|
||||
}
|
||||
|
||||
private installLifecycleHooks(): void {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
void this.save();
|
||||
}
|
||||
});
|
||||
window.addEventListener('beforeunload', () => {
|
||||
void this.save();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const gameRuntime = new GameRuntime();
|
||||
@@ -0,0 +1,26 @@
|
||||
import { create } from 'zustand';
|
||||
import type { GameView } from './viewModel';
|
||||
|
||||
/**
|
||||
* The view store. The runtime owns the authoritative engine state and pushes a
|
||||
* fresh view snapshot here at ~10 fps; React components subscribe to slices of
|
||||
* it. The store is a dumb mirror plus an event log — no game logic lives here.
|
||||
*/
|
||||
|
||||
const MAX_LOG_LINES = 50;
|
||||
|
||||
export interface GameStoreState extends GameView {
|
||||
log: string[];
|
||||
setView: (view: GameView) => void;
|
||||
appendLog: (line: string) => void;
|
||||
}
|
||||
|
||||
export const useGameStore = create<GameStoreState>((set) => ({
|
||||
resources: [],
|
||||
activeActionId: null,
|
||||
actionName: null,
|
||||
actionProgress: 0,
|
||||
log: [],
|
||||
setView: (view) => set(view),
|
||||
appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })),
|
||||
}));
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Content } from '../content/schema';
|
||||
import type { GameState } from '../engine/game';
|
||||
|
||||
/**
|
||||
* Pure mapping from engine state to the view model the React shell renders.
|
||||
* No React, no store — just data shaping, so it can be unit-tested directly.
|
||||
*/
|
||||
|
||||
export interface ResourceView {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface GameView {
|
||||
resources: ResourceView[];
|
||||
activeActionId: string | null;
|
||||
actionName: string | null;
|
||||
/** Progress of the active action, clamped to 0..1. */
|
||||
actionProgress: number;
|
||||
}
|
||||
|
||||
export function toView(state: GameState, content: Content): GameView {
|
||||
const resources: ResourceView[] = content.resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
amount: state.resources[resource.id] ?? 0,
|
||||
}));
|
||||
|
||||
const action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined;
|
||||
const actionProgress = action ? Math.min(1, state.actionElapsedMs / action.durationMs) : 0;
|
||||
|
||||
return {
|
||||
resources,
|
||||
activeActionId: state.activeActionId,
|
||||
actionName: action ? action.name : null,
|
||||
actionProgress,
|
||||
};
|
||||
}
|
||||
|
||||
/** Render an offline gap as `45s`, `1m 30s`, `2m`, `1h 1m`, `2h`. */
|
||||
export function formatOfflineDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds}s`;
|
||||
}
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
if (totalMinutes < 60) {
|
||||
const seconds = totalSeconds % 60;
|
||||
return seconds === 0 ? `${totalMinutes}m` : `${totalMinutes}m ${seconds}s`;
|
||||
}
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
|
||||
}
|
||||
Reference in New Issue
Block a user