test/capsule-pig.test.js
Rama main · réplica de lectura del repositorio original.
var test = require("node:test");
var assert = require("node:assert/strict");
var fs = require("node:fs");
var os = require("node:os");
var path = require("node:path");
var testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "clay-capsule-pig-"));
process.env.CLAY_HOME = testRoot;
var pigLogic = require("../lib/capsule-pig-logic");
var registry = require("../lib/tools-registry");
var capsuleFloor = require("../lib/capsule-display-floor");
var toolStorage = require("../lib/tool-storage");
var serverRuntimes = require("../lib/capsule-server-runtimes");
var serverTools = require("../lib/server-tools");
test.after(function () { fs.rmSync(testRoot, { recursive: true, force: true }); });
function scriptedDice(faces) {
var index = 0;
return function () {
if (index >= faces.length) throw new Error("The test rolled more dice than it scripted.");
return faces[index++];
};
}
function runtimeFor(userId, faces) {
var ctx = { userId: userId, multiUser: true };
registry.listTools(ctx);
return serverRuntimes.createRuntime("pig", ctx, faces ? { rollDie: scriptedDice(faces) } : {});
}
var USER = { userId: "pig-user", actor: "human", callerId: "user" };
var MATE = { userId: "pig-user", actor: "mate", callerId: "mate-folder" };
test("the shipped Pig Capsule is discoverable, server-runtime, and has a usable floor", function () {
var ctx = { userId: "pig-discovery", multiUser: true };
var manifests = registry.listTools(ctx);
var pig = manifests.filter(function (manifest) { return manifest.id === "pig"; })[0];
assert.ok(pig, "Pig should seed into a fresh tools root");
assert.strictEqual(pig.runtime, "server");
assert.ok(pig.description && pig.description.length > 0);
assert.ok(pig.useWhen && pig.useWhen.length > 0);
assert.match(pig.skills, /clay_tool_act/);
assert.match(pig.skills, /`roll`, `hold`, or `reset`/);
assert.strictEqual(capsuleFloor.hasUsableFloor(pig), true);
assert.strictEqual(serverRuntimes.hasRuntime("pig"), true);
assert.strictEqual(serverRuntimes.hasRuntime("not-shipped"), false);
});
test("the shipped Display binds only to fields the projection publishes", function () {
var displayCtx = { userId: "pig-display", multiUser: true };
registry.listTools(displayCtx);
var uiTree = registry.getTool(displayCtx, "pig").uiTree;
var charts = [];
(function walkCharts(node) {
if (!node || typeof node !== "object") return;
if (node.type === "chart") charts.push(node);
(node.children || []).forEach(walkCharts);
if (node.else) walkCharts(node.else);
})(uiTree);
var progress = charts.filter(function (chart) { return chart.props.kind === "progress"; });
assert.strictEqual(progress.length, 2);
progress.forEach(function (chart) {
assert.strictEqual(chart.props.max, pigLogic.TARGET_SCORE);
});
var projection = pigLogic.project(pigLogic.newGame());
charts.forEach(function (chart) {
assert.ok(Array.isArray(projection[chart.bind]), chart.bind + " must be a published collection");
});
assert.ok(Array.isArray(projection.recentRolls));
assert.strictEqual(typeof projection.turnTotalText, "string");
assert.strictEqual(typeof projection.statusText, "string");
assert.strictEqual(typeof projection.lastRollText, "string");
var buttons = [];
(function walk(node) {
if (!node || typeof node !== "object") return;
if (node.type === "button") buttons.push(node);
(node.children || []).forEach(walk);
if (node.else) walk(node.else);
})(uiTree);
var actions = buttons.map(function (button) { return button.action; });
assert.deepStrictEqual(actions.slice().sort(), ["hold", "hold", "reset", "reset", "roll", "roll"]);
var offTurn = buttons.filter(function (button) { return button.props.disabled === true; });
assert.strictEqual(offTurn.length, 2, "roll and hold have a disabled variant off the user's turn");
});
test("rolling accumulates, a 1 busts the turn, and holding banks the total", function () {
var state = pigLogic.newGame();
assert.strictEqual(state.turn, "user");
pigLogic.roll(state, "user", scriptedDice([4]));
assert.strictEqual(state.turnTotal, 4);
pigLogic.roll(state, "user", scriptedDice([5]));
assert.strictEqual(state.turnTotal, 9);
assert.strictEqual(state.turn, "user");
pigLogic.hold(state, "user");
assert.strictEqual(state.scores.user, 9);
assert.strictEqual(state.turnTotal, 0);
assert.strictEqual(state.turn, "mate");
pigLogic.roll(state, "mate", scriptedDice([6]));
pigLogic.roll(state, "mate", scriptedDice([1]));
assert.strictEqual(state.turnTotal, 0);
assert.strictEqual(state.scores.mate, 0);
assert.strictEqual(state.turn, "user");
assert.match(state.recentRolls[state.recentRolls.length - 1].text, /rolled a 1 and lost 6/);
});
test("Logic refuses out-of-turn moves, post-game moves, and a Mate reset mid-game", function () {
var state = pigLogic.newGame();
assert.throws(function () { pigLogic.roll(state, "mate", scriptedDice([3])); }, /out of turn/);
assert.throws(function () { pigLogic.hold(state, "mate"); }, /out of turn/);
assert.strictEqual(state.turnTotal, 0);
assert.throws(function () { pigLogic.reset(state, "mate"); }, /Only the user may reset/);
assert.strictEqual(pigLogic.reset(state, "user").scores.user, 0);
var finished = pigLogic.newGame();
finished.scores.user = 96;
pigLogic.roll(finished, "user", scriptedDice([4]));
pigLogic.hold(finished, "user");
assert.strictEqual(finished.status, "complete");
assert.strictEqual(finished.winner, "user");
assert.strictEqual(finished.scores.user, 100);
assert.throws(function () { pigLogic.roll(finished, "user", scriptedDice([3])); }, /already over/);
assert.throws(function () { pigLogic.hold(finished, "mate"); }, /already over/);
// A completed game may be reset by either seat.
assert.strictEqual(pigLogic.reset(finished, "mate").status, "playing");
});
test("the projection restates state for the floor Display without inventing meaning", function () {
var state = pigLogic.newGame();
var idle = pigLogic.project(state);
assert.strictEqual(idle.userTurn, true);
assert.strictEqual(idle.recentRolls.length, 0);
assert.deepStrictEqual(idle.userScoreSeries, [{ seat: "You", value: 0 }]);
assert.deepStrictEqual(idle.mateScoreSeries, [{ seat: "Your Mate", value: 0 }]);
assert.match(idle.turnTotalText, /Turn total 0 of 100/);
pigLogic.roll(state, "user", scriptedDice([1]));
var passed = pigLogic.project(state);
assert.strictEqual(passed.turn, "mate");
assert.strictEqual(passed.userTurn, false);
assert.strictEqual(passed.recentRolls.length, 1);
assert.strictEqual(passed.target, pigLogic.TARGET_SCORE);
});
test("a corrupt stored game is replaced rather than trusted", function () {
assert.strictEqual(pigLogic.normalizeState(null).status, "playing");
assert.strictEqual(pigLogic.normalizeState({ turn: "referee", status: "playing" }).scores.user, 0);
assert.strictEqual(pigLogic.normalizeState({ turn: "user", status: "paused" }).turn, "user");
var repaired = pigLogic.normalizeState({ turn: "user", status: "playing", scores: { user: -5, mate: "x" }, turnTotal: 3.5, recentRolls: "nope" });
assert.deepStrictEqual(repaired.scores, { user: 0, mate: 0 });
assert.strictEqual(repaired.turnTotal, 0);
assert.deepStrictEqual(repaired.recentRolls, []);
var partialRolls = pigLogic.normalizeState({
turn: "user",
status: "playing",
recentRolls: [{ id: "roll-1", seat: "user", face: 4, text: "You rolled a 4." }, { id: "roll-2", text: "not a roll" }],
});
assert.strictEqual(partialRolls.recentRolls.length, 1);
});
test("recentRolls holds rolls only, including a bust, and never a hold or a win", function () {
var state = pigLogic.newGame();
pigLogic.roll(state, "user", scriptedDice([4]));
pigLogic.roll(state, "user", scriptedDice([2]));
pigLogic.hold(state, "user");
pigLogic.roll(state, "mate", scriptedDice([1]));
assert.deepStrictEqual(state.recentRolls.map(function (entry) { return entry.face; }), [4, 2, 1]);
state.recentRolls.forEach(function (entry) {
assert.match(entry.text, /rolled a/);
assert.doesNotMatch(entry.text, /held|banked|won/);
});
var winning = pigLogic.newGame();
winning.scores.user = 98;
pigLogic.roll(winning, "user", scriptedDice([3]));
pigLogic.hold(winning, "user");
assert.strictEqual(winning.winner, "user");
assert.strictEqual(winning.recentRolls.length, 1, "winning adds no entry");
// The collection stays bounded no matter how long the game runs.
var long = pigLogic.newGame();
for (var i = 0; i < 40; i++) {
if (long.turn !== "user") pigLogic.roll(long, "mate", scriptedDice([2]));
else pigLogic.roll(long, "user", scriptedDice([2]));
}
assert.strictEqual(long.recentRolls.length, 12);
});
test("one act pipeline: user and Mate share the same persisted game", async function () {
var runtime = runtimeFor("pig-user", [3, 2, 5]);
var start = await runtime.snapshot(USER);
assert.strictEqual(start.turn, "user");
assert.strictEqual(start.scores.user, 0);
var rolled = await runtime.act(USER, "roll", {});
assert.strictEqual(rolled.state.turnTotal, 3);
// The act carries its own causality for a watching Display.
assert.strictEqual(rolled.event.actor, "user");
assert.strictEqual(rolled.event.action, "roll");
assert.strictEqual(rolled.event.previous.turnTotal, 0);
assert.deepStrictEqual(rolled.event.next, rolled.state);
assert.strictEqual(rolled.event.seq, rolled.state.eventSeq);
await runtime.act(USER, "hold", {});
await assert.rejects(function () { return runtime.act(USER, "roll", {}); }, /out of turn/);
var mateRolled = await runtime.act(MATE, "roll", {});
assert.strictEqual(mateRolled.state.turnTotal, 2);
assert.strictEqual(mateRolled.state.scores.user, 3);
// A separate runtime instance for the same user reads the same stored game,
// and the Mate sees exactly what the user sees.
var reopened = runtimeFor("pig-user");
var userView = await reopened.snapshot(USER);
var mateView = await reopened.snapshot(MATE);
assert.deepStrictEqual(userView, mateView);
assert.strictEqual(userView.scores.user, 3);
assert.strictEqual(userView.turn, "mate");
await assert.rejects(function () { return reopened.act(USER, "newGame", {}); }, /Unknown Pig action/);
await assert.rejects(function () { return reopened.snapshot({ userId: "pig-user" }); }, /caller seat/);
});
test("concurrent acts serialize across separately created runtime instances", async function () {
// Production creates a runtime per request, so the lock has to hold across
// instances for one user's stored game, not just within one instance.
var rollerA = runtimeFor("pig-race", [3]);
var rollerB = runtimeFor("pig-race", [4]);
assert.notStrictEqual(rollerA, rollerB);
// Two rolls that arrive together must both land on the running total. An
// unserialized read-modify-write would read 0 twice and lose one of them.
var rolls = (await Promise.all([rollerA.act(USER, "roll", {}), rollerB.act(USER, "roll", {})]))
.map(function (result) { return result.state; });
var latest = rolls[0].recentRolls.length === 2 ? rolls[0] : rolls[1];
assert.strictEqual(latest.turnTotal, 7);
assert.strictEqual(latest.recentRolls.length, 2);
assert.deepStrictEqual(latest.recentRolls.map(function (entry) { return entry.face; }).slice().sort(), [3, 4]);
// Two holds that arrive together cannot both be in turn: the first banks and
// passes play, and the second is refused by the same rule a Mate would hit.
var holderA = runtimeFor("pig-race");
var holderB = runtimeFor("pig-race");
var settled = await Promise.allSettled([holderA.act(USER, "hold", {}), holderB.act(USER, "hold", {})]);
var fulfilled = settled.filter(function (entry) { return entry.status === "fulfilled"; });
var rejected = settled.filter(function (entry) { return entry.status === "rejected"; });
assert.strictEqual(fulfilled.length, 1);
assert.strictEqual(rejected.length, 1);
assert.match(rejected[0].reason.message, /out of turn/);
var after = await runtimeFor("pig-race").snapshot(USER);
assert.strictEqual(after.scores.user, 7, "the turn total is banked exactly once");
assert.strictEqual(after.turn, "mate");
assert.strictEqual(after.turnTotal, 0);
// A concurrent Mate act on the same game queues behind the user's, and the
// rule it meets is decided by the state the earlier act committed.
var userRoller = runtimeFor("pig-race", [5]);
var mateRoller = runtimeFor("pig-race", [6]);
var mixed = await Promise.allSettled([mateRoller.act(MATE, "roll", {}), userRoller.act(USER, "roll", {})]);
assert.strictEqual(mixed[0].status, "fulfilled", "the mate is in turn and rolls");
assert.strictEqual(mixed[1].status, "rejected");
assert.match(mixed[1].reason.message, /out of turn/);
var final = await runtimeFor("pig-race").snapshot(MATE);
assert.strictEqual(final.turnTotal, 6, "exactly one roll was applied");
});
test("each user's game is stored in their own Capsule datastore", async function () {
var first = runtimeFor("pig-alpha", [6]);
await first.act(USER, "roll", {});
var second = runtimeFor("pig-beta");
var otherView = await second.snapshot(USER);
assert.strictEqual(otherView.turnTotal, 0);
assert.strictEqual(otherView.recentRolls.length, 0);
var stored = await toolStorage.createToolStorage({ userId: "pig-alpha", multiUser: true }, "pig").get("game");
assert.strictEqual(stored.state.turnTotal, 6);
});
test("the human WebSocket path and the Mate MCP path drive one shared game", async function () {
var sent = [];
var socket = { readyState: 1, send: function (payload) { sent.push(JSON.parse(payload)); } };
var projects = new Map();
projects.set("home", { forEachClient: function (fn) { fn(socket); } });
var tools = serverTools.attachTools({
users: { isMultiUser: function () { return false; }, findUserById: function () { return null; } },
projects: projects,
});
tools.installedManifests("default");
var userState = await tools.controlForUser("default", "pig", "act", { actionId: "roll", args: {} });
assert.ok(userState.turnTotal > 0 || userState.turn === "mate", "a roll either builds the turn or busts it");
// State only: a Mate never sees Display, and neither surface returns one.
assert.strictEqual(userState.ui, undefined);
assert.strictEqual(userState.controls, undefined);
// Every successful act pushes its causal event to the user's Displays.
assert.strictEqual(sent.length, 1);
assert.strictEqual(sent[0].type, "tool_server_event");
assert.strictEqual(sent[0].toolId, "pig");
assert.strictEqual(sent[0].event.actor, "user");
assert.strictEqual(sent[0].event.action, "roll");
assert.deepStrictEqual(sent[0].event.next, userState);
sent.length = 0;
var mateView = await tools.controlForMate("default", "mate-folder", "pig", "snapshot", {});
assert.strictEqual(mateView.ui, undefined);
assert.strictEqual(mateView.controls, undefined);
assert.strictEqual(mateView.turnTotal, userState.turnTotal);
assert.strictEqual(mateView.turn, userState.turn);
// Reading pushes nothing; only a state change does.
assert.deepStrictEqual(sent, []);
if (userState.turn === "user") {
await assert.rejects(function () {
return tools.controlForMate("default", "mate-folder", "pig", "act", { actionId: "roll", args: {} });
}, /out of turn/);
// A refused act changed nothing, so nothing is pushed.
assert.deepStrictEqual(sent, []);
}
sent.length = 0;
assert.strictEqual(tools.handleMessage(socket, { type: "tool_server_control", toolId: "pig", kind: "snapshot", requestId: "snap-1" }), true);
for (var i = 0; i < 50 && sent.length === 0; i++) await new Promise(function (resolve) { setTimeout(resolve, 5); });
assert.strictEqual(sent.length, 1, "only the requesting socket is answered");
assert.strictEqual(sent[0].type, "tool_server_state");
assert.strictEqual(sent[0].ok, true);
assert.strictEqual(sent[0].requestId, "snap-1");
assert.strictEqual(sent[0].state.target, pigLogic.TARGET_SCORE);
assert.strictEqual(sent[0].state.userScoreSeries.length, 1);
});
test("acts push ordered causal events to every open Display of the acting user", async function () {
var userId = "pig-events";
var eventsA = [];
var eventsB = [];
var eventsStranger = [];
function client(userIdOwner, sink) {
return {
readyState: 1,
_clayUser: { id: userIdOwner },
send: function (payload) { sink.push(JSON.parse(payload)); },
};
}
var windowA = client(userId, eventsA);
var windowB = client(userId, eventsB);
var stranger = client("someone-else", eventsStranger);
var projects = new Map();
projects.set("home", { forEachClient: function (fn) { fn(windowA); fn(windowB); fn(stranger); } });
// One scripted die shared across per-act runtime instances, exactly as
// production creates a runtime per call over one stored game.
var die = scriptedDice([2, 3, 1]);
var tools = serverTools.attachTools({
users: {
isMultiUser: function () { return true; },
findUserById: function (id) { return { id: id }; },
},
projects: projects,
serverRuntimes: {
hasRuntime: serverRuntimes.hasRuntime,
createRuntime: function (toolId, ctx) {
return serverRuntimes.createRuntime(toolId, ctx, { rollDie: die });
},
},
});
tools.installedManifests(userId);
await tools.controlForUser(userId, "pig", "act", { actionId: "roll", args: {} });
await tools.controlForUser(userId, "pig", "act", { actionId: "hold", args: {} });
await tools.controlForMate(userId, "mate-folder", "pig", "act", { actionId: "roll", args: {} });
await tools.controlForMate(userId, "mate-folder", "pig", "act", { actionId: "roll", args: {} });
await assert.rejects(function () {
return tools.controlForMate(userId, "mate-folder", "pig", "act", { actionId: "roll", args: {} });
}, /out of turn/);
// Both of the user's windows saw the same stream; a stranger saw nothing.
assert.deepStrictEqual(eventsA, eventsB);
assert.deepStrictEqual(eventsStranger, []);
assert.strictEqual(eventsA.length, 4, "four state changes, four events, and no event for the refused act");
eventsA.forEach(function (msg) {
assert.strictEqual(msg.type, "tool_server_event");
assert.strictEqual(msg.toolId, "pig");
});
var events = eventsA.map(function (msg) { return msg.event; });
assert.deepStrictEqual(events.map(function (e) { return e.actor; }), ["user", "user", "mate", "mate"]);
assert.deepStrictEqual(events.map(function (e) { return e.action; }), ["roll", "hold", "roll", "roll"]);
// Ordered stream: seq grows by exactly one per act, and previous chains onto
// the prior event's next, so the Display can replay the game step by step.
for (var i = 0; i < events.length; i++) {
assert.strictEqual(events[i].seq, events[i].next.eventSeq);
if (i > 0) {
assert.strictEqual(events[i].seq, events[i - 1].seq + 1);
assert.deepStrictEqual(events[i].previous, events[i - 1].next);
}
}
// Logic declares Mate engagement on the event: the user's hold that hands
// the turn over engages the Mate; the Mate's own acts never engage anyone.
assert.strictEqual(events[0].engage, undefined, "a roll that keeps the user's turn engages nobody");
assert.deepStrictEqual(events[1].engage, { kind: "turn" });
assert.strictEqual(events[2].engage, undefined);
assert.strictEqual(events[3].engage, undefined);
// The Mate's bust is fully attributed: turn total 3 wiped, play passed back.
var bust = events[3];
assert.strictEqual(bust.previous.turnTotal, 3);
assert.strictEqual(bust.next.turnTotal, 0);
assert.strictEqual(bust.next.turn, "user");
// A reset starts a new game but never restarts the event clock, so a
// Display that saw event N can never mistake the fresh game for stale news.
await tools.controlForUser(userId, "pig", "act", { actionId: "reset", args: {} });
var resetEvent = eventsA[4].event;
assert.strictEqual(resetEvent.action, "reset");
assert.deepStrictEqual(resetEvent.engage, { kind: "start" }, "a fresh game seats the Mate at the table");
assert.strictEqual(resetEvent.seq, events[3].seq + 1);
assert.strictEqual(resetEvent.next.scores.user, 0);
assert.strictEqual(resetEvent.previous.scores.user, 2);
});
test("the die stays inside Logic and stays on a real die face", async function () {
var runtime = runtimeFor("pig-random");
var seen = Object.create(null);
for (var i = 0; i < 60; i++) {
var state = (await runtime.act(USER, "roll", {})).state;
if (state.lastRoll !== null) seen[state.lastRoll] = true;
if (state.turn !== "user") await runtime.act(MATE, "hold", {});
if (state.status === "complete") await runtime.act(USER, "reset", {});
assert.ok(state.lastRoll >= 1 && state.lastRoll <= 6, "roll must be a die face");
}
assert.ok(Object.keys(seen).length > 1, "rolls should not be constant");
});