Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/notes/ideas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
55 changes: 55 additions & 0 deletions packages/core/src/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ export interface Dispatcher<M extends Model, G extends Msg> {
subscribe(callback: (snapshot: Snapshot<M>) => 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,
Expand All @@ -71,6 +76,12 @@ export function createDispatcher<
const time = options.timeProvider || { now: () => Date.now() };
const maxLogSize = options.maxLogSize ?? 10000;
let activeSubs: readonly Subscription<G>[] = [];

const updateHistory: number[] = [];
const commitHistory: number[] = [];
let lastCommitTime = time.now();
const fpsHistory: number[] = [];

const deepFreeze = (obj: unknown): unknown => {
if (
options.devMode &&
Expand Down Expand Up @@ -104,8 +115,20 @@ export function createDispatcher<
if (isShutdown) return;
pendingNotify = false;
const snapshot = currentModel as Snapshot<M>;

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();
});
};
Expand All @@ -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,
Expand Down Expand Up @@ -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)),
};
},
};
}
15 changes: 15 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
16 changes: 16 additions & 0 deletions packages/devtools/package.json
Original file line number Diff line number Diff line change
@@ -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:*"
}
}
94 changes: 94 additions & 0 deletions packages/devtools/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { h, VNode, Dispatcher, Model, Msg } from "@causaloop/core";
import { createSnabbdomRenderer } from "@causaloop/platform-browser";

export interface DevToolsOptions<M extends Model, G extends Msg> {
readonly dispatcher: Dispatcher<M, G>;
readonly container: HTMLElement;
}

export function createDevTools<M extends Model, G extends Msg>(
options: DevToolsOptions<M, G>,
) {
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, () => {}));
}
16 changes: 16 additions & 0 deletions packages/devtools/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"references": [
{
"path": "../core"
},
{
"path": "../platform-browser"
}
]
}
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions tests/stability.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ 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/");

// Start background tasks
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;

Expand Down
3 changes: 3 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
},
{
"path": "./packages/app-web"
},
{
"path": "./packages/devtools"
}
]
}