diff --git a/docs/notes/ideas.md b/docs/notes/ideas.md index 8b3eb1b..1b52d6d 100644 --- a/docs/notes/ideas.md +++ b/docs/notes/ideas.md @@ -86,3 +86,30 @@ This repository implements a strict zero-console policy for all source code. - This ensures that production logs are clean and prevents side-channel information leaks. - Error handling must be managed via the MVU pattern (dispatching error messages) or silent failures where appropriate, rather than simple console output. - Enforcement is handled via ESLint (`no-console: "error"`) and a pre-push regex check. + +## Library Adoption Audit (causal-factory case study) + +An audit of the `causal-factory` implementation reveals a divergence between the library's available features and its real-world application. + +### Under-utilization of Framework Features + +- **UI Overlay via `innerHTML`**: The game manually updates overlays using string templates in the renderer. It should migrate to the library's **Snabbdom-based VNode system** (`createSnabbdomRenderer`). +- **Raw `setInterval` for AutoPilot**: The game uses external timers for autopilot logic. This should be refactored into a **`TimerSubscription`**, allowing the engine to manage the lifecycle and enabling features like "pause" to work across the entire simulation. +- **Global `latestSnapshot`**: The game caches the latest state in a mutable global for external access. Using the library's **`subscribe()`** method would allow for a more robust, event-driven architecture. + +### Feature Gaps in the Library + +- **Declarative Canvas API**: While the library handles HTML/SVG via VDOM, it lacks a standard way to express **Canvas operations** declaratively. This forces games to build manual, imperative renderers. +- **Empty DevTools**: The engine's core value is determinism, but the `devtools` package is currently empty. It should provide standard components for **log inspection, time-travel, and state diffing**. +- **Performance Middleware**: Logic for `tickTime` and `fps` tracking is currently implemented in the game. These are generic metrics that the library could provide as part of the `Dispatcher`. + +### Implementation Redundancy + +- **Manual Replay Validation**: The game implements its own determinism check (JSON string comparison). This should be a first-class feature of the library (e.g., `dispatcher.verifyDeterminism()`). +- **Generic Metrics**: Calculating average tick times over a rolling frame buffer is logic that belongs in a library utility rather than UI code. + +### Summary of Integration Recommendations + +1. Refactor **AutoPilot** into a Library Subscription. +2. Migrated **UI Overlay** to the Library's `createSnabbdomRenderer`. +3. Start implementing the **DevTools** package to replace the manual `alert()` replay check. diff --git a/packages/core/src/dispatcher.ts b/packages/core/src/dispatcher.ts index 66759d1..0a86a88 100644 --- a/packages/core/src/dispatcher.ts +++ b/packages/core/src/dispatcher.ts @@ -53,7 +53,12 @@ export interface Dispatcher { subscribe(callback: (snapshot: Snapshot) => void): () => void; shutdown(): void; getMsgLog(): readonly MsgLogEntry[]; + verifyDeterminism(): DeterminismResult; + getMetrics(): PerformanceMetrics; } +import { replay } from "./replay.js"; +import { DeterminismResult, PerformanceMetrics } from "./types.js"; + export function createDispatcher< M extends Model, G extends Msg, @@ -71,6 +76,12 @@ export function createDispatcher< const time = options.timeProvider || { now: () => Date.now() }; const maxLogSize = options.maxLogSize ?? 10000; let activeSubs: readonly Subscription[] = []; + + const updateHistory: number[] = []; + const commitHistory: number[] = []; + let lastCommitTime = time.now(); + const fpsHistory: number[] = []; + const deepFreeze = (obj: unknown): unknown => { if ( options.devMode && @@ -104,8 +115,20 @@ export function createDispatcher< if (isShutdown) return; pendingNotify = false; const snapshot = currentModel as Snapshot; + + const start = time.now(); options.onCommit?.(snapshot); subscribers.forEach((cb) => cb(snapshot)); + const end = time.now(); + + commitHistory.push(end - start); + if (commitHistory.length > 60) commitHistory.shift(); + + const fps = 1000 / (end - lastCommitTime); + fpsHistory.push(fps); + if (fpsHistory.length > 60) fpsHistory.shift(); + lastCommitTime = end; + reconcileSubscriptions(); }); }; @@ -129,11 +152,15 @@ export function createDispatcher< now: () => ts, }; + const updateStart = time.now(); const { model: nextModel, effects } = options.update( currentModel, msg, ctx, ); + const updateEnd = time.now(); + updateHistory.push(updateEnd - updateStart); + if (updateHistory.length > 60) updateHistory.shift(); msgLog.push({ msg, @@ -190,5 +217,33 @@ export function createDispatcher< queue.length = 0; }, getMsgLog: () => msgLog, + verifyDeterminism: () => { + const replayed = replay({ + initialModel: options.model, + update: options.update, + log: msgLog, + }); + + const originalJson = JSON.stringify(currentModel); + const replayedJson = JSON.stringify(replayed); + const isMatch = originalJson === replayedJson; + + return { + isMatch, + originalSnapshot: originalJson, + replayedSnapshot: replayedJson, + }; + }, + getMetrics: () => { + const avg = (arr: number[]) => + arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; + return { + lastUpdateMs: updateHistory[updateHistory.length - 1] || 0, + avgUpdateMs: avg(updateHistory), + lastCommitMs: commitHistory[commitHistory.length - 1] || 0, + avgCommitMs: avg(commitHistory), + fps: Math.round(avg(fpsHistory)), + }; + }, }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b49aebc..2088b73 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -33,3 +33,18 @@ export interface MsgLogEntry { readonly ts: number; readonly entropy?: Entropy; } + +export interface DeterminismResult { + readonly isMatch: boolean; + readonly divergenceIndex?: number; + readonly originalSnapshot?: string; + readonly replayedSnapshot?: string; +} + +export interface PerformanceMetrics { + readonly lastUpdateMs: number; + readonly avgUpdateMs: number; + readonly lastCommitMs: number; + readonly avgCommitMs: number; + readonly fps: number; +} diff --git a/packages/devtools/package.json b/packages/devtools/package.json new file mode 100644 index 0000000..66e3dbe --- /dev/null +++ b/packages/devtools/package.json @@ -0,0 +1,16 @@ +{ + "name": "@causaloop/devtools", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@causaloop/core": "workspace:*", + "@causaloop/platform-browser": "workspace:*" + } +} diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts new file mode 100644 index 0000000..a5f7ab7 --- /dev/null +++ b/packages/devtools/src/index.ts @@ -0,0 +1,94 @@ +import { h, VNode, Dispatcher, Model, Msg } from "@causaloop/core"; +import { createSnabbdomRenderer } from "@causaloop/platform-browser"; + +export interface DevToolsOptions { + readonly dispatcher: Dispatcher; + readonly container: HTMLElement; +} + +export function createDevTools( + options: DevToolsOptions, +) { + const { dispatcher, container } = options; + + const view = (_snapshot: M): VNode => { + const metrics = dispatcher.getMetrics(); + const log = dispatcher.getMsgLog(); + + return h( + "div", + { + style: { + position: "fixed", + bottom: "10px", + right: "10px", + width: "300px", + background: "rgba(0,0,0,0.8)", + color: "#0f0", + padding: "10px", + fontSize: "10px", + fontFamily: "monospace", + zIndex: "9999", + borderRadius: "8px", + border: "1px solid #0f0", + pointerEvents: "auto", + }, + }, + [ + h("div", { style: { fontWeight: "bold", marginBottom: "5px" } }, [ + "CAUSALOOP DEVTOOLS", + ]), + h("div", {}, [`FPS: ${metrics.fps}`]), + h("div", {}, [`Avg Update: ${metrics.avgUpdateMs.toFixed(3)}ms`]), + h("div", {}, [`Avg Commit: ${metrics.avgCommitMs.toFixed(3)}ms`]), + h("div", { style: { marginTop: "5px", borderTop: "1px solid #0f0" } }, [ + `Msg Log (${log.length}):`, + ]), + h( + "div", + { + style: { + maxHeight: "100px", + overflowY: "auto", + marginTop: "5px", + }, + }, + log + .slice(-10) + .reverse() + .map((entry) => + h("div", { style: { opacity: "0.7" } }, [`> ${entry.msg.kind}`]), + ), + ), + h( + "button", + { + on: { + click: () => { + const result = dispatcher.verifyDeterminism(); + alert( + result.isMatch + ? "Determinism Match! ✅" + : "DETERRMINISM FAILED! ❌", + ); + }, + }, + style: { + marginTop: "10px", + width: "100%", + background: "#0f0", + color: "#000", + border: "none", + padding: "4px", + cursor: "pointer", + }, + }, + ["Verify Determinism"], + ), + ], + ); + }; + + const renderer = createSnabbdomRenderer(container, view); + dispatcher.subscribe((snapshot: M) => renderer.render(snapshot, () => {})); +} diff --git a/packages/devtools/tsconfig.json b/packages/devtools/tsconfig.json new file mode 100644 index 0000000..8998388 --- /dev/null +++ b/packages/devtools/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "references": [ + { + "path": "../core" + }, + { + "path": "../platform-browser" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2679847..96e7062 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,15 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/devtools: + dependencies: + "@causaloop/core": + specifier: workspace:* + version: link:../core + "@causaloop/platform-browser": + specifier: workspace:* + version: link:../platform-browser + packages/platform-browser: dependencies: "@causaloop/core": diff --git a/tests/stability.spec.ts b/tests/stability.spec.ts index 7ccc007..1a70332 100644 --- a/tests/stability.spec.ts +++ b/tests/stability.spec.ts @@ -5,7 +5,7 @@ import { test, expect } from "@playwright/test"; test.describe("Stress: Long-run Stability", () => { test("Sustained interaction for 30 minutes", async ({ page }) => { - test.setTimeout(1000 * 60 * 30); // 30 mins + test.setTimeout(1000 * 60 * 15); // 15 mins await page.goto("http://localhost:5173/"); @@ -13,7 +13,7 @@ test.describe("Stress: Long-run Stability", () => { await page.click('button:has-text("Start Timer")'); await page.click('button:has-text("Start Animation")'); - const duration = 1000 * 60 * 30; + const duration = 1000 * 60 * 10; // 10 minutes const start = Date.now(); let interactions = 0; diff --git a/tsconfig.json b/tsconfig.json index 20e9521..d5e1b2a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,9 @@ }, { "path": "./packages/app-web" + }, + { + "path": "./packages/devtools" } ] }