clay-server 4.0.0-beta.13 → 4.0.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/lib/capsule-display-floor.js +51 -0
  2. package/lib/capsule-frame-server.js +229 -0
  3. package/lib/capsule-pig-logic.js +268 -0
  4. package/lib/capsule-server-runtimes.js +41 -0
  5. package/lib/capsule-tictactoe-logic.js +260 -0
  6. package/lib/capsules/pig/display.js +190 -0
  7. package/lib/capsules/pig/manifest.json +9 -0
  8. package/lib/capsules/pig/ui.json +46 -0
  9. package/lib/capsules/tictactoe/manifest.json +9 -0
  10. package/lib/capsules/tictactoe/ui.json +203 -0
  11. package/lib/project-capsule-catalog.js +9 -1
  12. package/lib/project-connection.js +1 -1
  13. package/lib/project-log-feedback-delivery.js +76 -0
  14. package/lib/project-logs.js +4 -0
  15. package/lib/project-pair-lifecycle.js +7 -12
  16. package/lib/project-session-pair.js +29 -39
  17. package/lib/project-worker-proposal.js +203 -72
  18. package/lib/project.js +13 -0
  19. package/lib/public/css/capsule-ui.css +18 -0
  20. package/lib/public/css/home-session-actions.css +66 -0
  21. package/lib/public/css/home-sidebar.css +66 -0
  22. package/lib/public/css/mobile-nav.css +80 -0
  23. package/lib/public/css/sidebar.css +92 -0
  24. package/lib/public/css/worker-proposal.css +34 -1
  25. package/lib/public/modules/app-messages.js +14 -1
  26. package/lib/public/modules/home-conversations-sheet.js +102 -48
  27. package/lib/public/modules/home-session-actions.js +6 -0
  28. package/lib/public/modules/home-sidebar-chat-list.js +101 -41
  29. package/lib/public/modules/home-tool-frame.js +165 -0
  30. package/lib/public/modules/home-tools.js +129 -1
  31. package/lib/public/modules/session-hierarchy.js +54 -0
  32. package/lib/public/modules/sidebar-mobile.js +35 -6
  33. package/lib/public/modules/sidebar-session-hierarchy.js +206 -0
  34. package/lib/public/modules/sidebar-sessions.js +49 -7
  35. package/lib/public/modules/worker-proposal-state.js +16 -0
  36. package/lib/public/modules/worker-proposal.js +49 -12
  37. package/lib/sdk-bridge.js +16 -8
  38. package/lib/sdk-message-processor.js +34 -4
  39. package/lib/server-home-chat.js +12 -1
  40. package/lib/server-tools.js +97 -9
  41. package/lib/server.js +3 -0
  42. package/lib/session-driver-eligibility.js +20 -165
  43. package/lib/session-pair-factory.js +31 -28
  44. package/lib/session-pair-mcp-server.js +5 -9
  45. package/lib/session-pair-prompts.js +15 -15
  46. package/lib/session-provenance.js +119 -0
  47. package/lib/session-spawn-mcp-server.js +1 -1
  48. package/lib/sessions.js +40 -2
  49. package/lib/tools-registry.js +44 -10
  50. package/lib/ws-schema.js +8 -3
  51. package/lib/yoke/adapters/claude.js +12 -0
  52. package/package.json +1 -1
@@ -0,0 +1,260 @@
1
+ // Server-side Logic for the Tic-Tac-Toe Capsule.
2
+ //
3
+ // Logic is the single source of truth. Every state change in the game happens
4
+ // through one of these functions and nowhere else: turn order, cell ownership,
5
+ // and win detection are enforced here, and the game record lives in the
6
+ // Capsule's own datastore. A human button click and a Mate clay_tool_act call
7
+ // arrive at the same act pipeline with the same rules, so neither party can
8
+ // break a rule the other is bound by, and neither sees state the other cannot.
9
+ //
10
+ // The two seats are "user" (X, first to move in a fresh game) and "mate" (O).
11
+ // The server resolves its own actor before calling in, and the human actor
12
+ // maps onto the user seat; seat is never read from caller-supplied text.
13
+ //
14
+ // Rules: seats alternate marks on a 3x3 board, cells numbered 0-8 left to
15
+ // right, top to bottom. Three in a row, column, or diagonal wins; a full board
16
+ // with no line is a draw. A finished game is frozen until someone resets it;
17
+ // only the user may reset a game in progress, because the Capsule belongs to
18
+ // the human, not to the Mate.
19
+
20
+ var GAME_DOC_ID = "game";
21
+ var BOARD_CELLS = 9;
22
+ var USER = "user";
23
+ var MATE = "mate";
24
+ var SEATS = [USER, MATE];
25
+ var SEAT_LABELS = { user: "You", mate: "Your Mate" };
26
+ var SEAT_MARKS = { user: "X", mate: "O" };
27
+ var WIN_LINES = [
28
+ [0, 1, 2], [3, 4, 5], [6, 7, 8],
29
+ [0, 3, 6], [1, 4, 7], [2, 5, 8],
30
+ [0, 4, 8], [2, 4, 6],
31
+ ];
32
+
33
+ // Every read-modify-write for one stored game runs to completion before the
34
+ // next one starts. Two acts that arrive together would otherwise both validate
35
+ // against the same turn, and the second write would silently discard the first.
36
+ var gameLocks = Object.create(null);
37
+
38
+ function runExclusive(key, operation) {
39
+ var previous = gameLocks[key] || Promise.resolve();
40
+ var settled = previous.then(operation, operation);
41
+ gameLocks[key] = settled.then(function () {}, function () {});
42
+ return settled;
43
+ }
44
+
45
+ function otherSeat(seat) {
46
+ return seat === USER ? MATE : USER;
47
+ }
48
+
49
+ function emptyBoard() {
50
+ var board = [];
51
+ for (var i = 0; i < BOARD_CELLS; i++) board.push(null);
52
+ return board;
53
+ }
54
+
55
+ function newGame() {
56
+ return {
57
+ status: "playing",
58
+ turn: USER,
59
+ board: emptyBoard(),
60
+ winner: null,
61
+ eventSeq: 0,
62
+ };
63
+ }
64
+
65
+ // Counters are ordering, not points: they only ever grow, so they carry no
66
+ // upper bound. eventSeq must survive a reset, because a Display that saw
67
+ // event N must treat anything at or below N as already rendered.
68
+ function safeCounter(value) {
69
+ return Number.isInteger(value) && value >= 0 ? value : 0;
70
+ }
71
+
72
+ // A stored game is data, not a promise about shape. Anything unreadable is
73
+ // replaced by a fresh game rather than trusted.
74
+ function normalizeState(stored) {
75
+ if (!stored || typeof stored !== "object" || Array.isArray(stored)) return newGame();
76
+ if (SEATS.indexOf(stored.turn) === -1) return newGame();
77
+ if (stored.status !== "playing" && stored.status !== "complete") return newGame();
78
+ var board = emptyBoard();
79
+ if (Array.isArray(stored.board)) {
80
+ for (var i = 0; i < BOARD_CELLS; i++) {
81
+ if (SEATS.indexOf(stored.board[i]) !== -1) board[i] = stored.board[i];
82
+ }
83
+ }
84
+ return {
85
+ status: stored.status,
86
+ turn: stored.turn,
87
+ board: board,
88
+ winner: SEATS.indexOf(stored.winner) !== -1 ? stored.winner : null,
89
+ eventSeq: safeCounter(stored.eventSeq),
90
+ };
91
+ }
92
+
93
+ function lineWinner(board) {
94
+ for (var i = 0; i < WIN_LINES.length; i++) {
95
+ var line = WIN_LINES[i];
96
+ var seat = board[line[0]];
97
+ if (seat && board[line[1]] === seat && board[line[2]] === seat) return seat;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function boardFull(board) {
103
+ for (var i = 0; i < BOARD_CELLS; i++) {
104
+ if (board[i] === null) return false;
105
+ }
106
+ return true;
107
+ }
108
+
109
+ function requirePlayableTurn(state, seat) {
110
+ if (state.status !== "playing") {
111
+ throw new Error("This game is already over. Reset it to play again.");
112
+ }
113
+ if (state.turn !== seat) {
114
+ throw new Error("This move is out of turn: " + SEAT_LABELS[state.turn] + " to play.");
115
+ }
116
+ }
117
+
118
+ function mark(state, seat, args) {
119
+ requirePlayableTurn(state, seat);
120
+ var cell = args && typeof args === "object" ? args.cell : undefined;
121
+ if (!Number.isInteger(cell) || cell < 0 || cell >= BOARD_CELLS) {
122
+ throw new Error("The cell must be an integer from 0 to 8.");
123
+ }
124
+ if (state.board[cell] !== null) {
125
+ throw new Error("Cell " + cell + " is already marked with " + SEAT_MARKS[state.board[cell]] + ".");
126
+ }
127
+ state.board[cell] = seat;
128
+ var winner = lineWinner(state.board);
129
+ if (winner) {
130
+ state.status = "complete";
131
+ state.winner = winner;
132
+ return state;
133
+ }
134
+ if (boardFull(state.board)) {
135
+ state.status = "complete";
136
+ state.winner = null;
137
+ return state;
138
+ }
139
+ state.turn = otherSeat(seat);
140
+ return state;
141
+ }
142
+
143
+ function reset(state, seat) {
144
+ if (state.status === "playing" && seat !== USER) {
145
+ throw new Error("Only the user may reset a game that is still in progress.");
146
+ }
147
+ return newGame();
148
+ }
149
+
150
+ function statusText(state) {
151
+ if (state.status !== "complete") {
152
+ return SEAT_LABELS[state.turn] + " (" + SEAT_MARKS[state.turn] + ") to play.";
153
+ }
154
+ if (state.winner === USER) return "You (X) won this game.";
155
+ if (state.winner === MATE) return "Your Mate (O) won this game.";
156
+ return "The game ended in a draw.";
157
+ }
158
+
159
+ // The projection a Display renders and a Mate reads. It adds no meaning that
160
+ // is absent from state: every derived field is a restatement of the fields
161
+ // above. The per-cell fields exist because the floor's grid buttons bind to
162
+ // one scalar each.
163
+ function project(state) {
164
+ var complete = state.status === "complete";
165
+ var userTurn = !complete && state.turn === USER;
166
+ var projection = {
167
+ status: state.status,
168
+ turn: state.turn,
169
+ winner: state.winner,
170
+ complete: complete,
171
+ userTurn: userTurn,
172
+ statusText: statusText(state),
173
+ eventSeq: state.eventSeq,
174
+ };
175
+ for (var i = 0; i < BOARD_CELLS; i++) {
176
+ var seat = state.board[i];
177
+ projection["cell" + i] = seat ? SEAT_MARKS[seat] : "";
178
+ projection["cell" + i + "Disabled"] = seat !== null || complete || !userTurn;
179
+ }
180
+ return projection;
181
+ }
182
+
183
+ // Causality for the live Display: {actor, action, previous, next} plus a
184
+ // monotonic seq. Sending only the new state would make a Mate's move teleport
185
+ // onto the human's screen; the event lets the Display replay and attribute it.
186
+ function buildEvent(seat, actionId, previous, next) {
187
+ return {
188
+ seq: next.eventSeq,
189
+ actor: seat,
190
+ action: actionId,
191
+ previous: previous,
192
+ next: next,
193
+ };
194
+ }
195
+
196
+ function seatFor(context) {
197
+ var actor = context && context.actor;
198
+ if (actor === "human" || actor === USER) return USER;
199
+ if (actor === MATE) return MATE;
200
+ throw new Error("The Capsule caller seat could not be determined.");
201
+ }
202
+
203
+ // One act pipeline. The human surface and the Mate MCP surface both land here.
204
+ function createRuntime(options) {
205
+ options = options || {};
206
+ var storage = options.storage;
207
+ var lockKey = options.lockKey || "tictactoe";
208
+ if (!storage) throw new Error("The Tic-Tac-Toe Capsule requires its datastore.");
209
+
210
+ async function writeState(state) {
211
+ await storage.put({ _id: GAME_DOC_ID, state: state });
212
+ return state;
213
+ }
214
+
215
+ async function readState() {
216
+ var doc = await storage.get(GAME_DOC_ID);
217
+ if (!doc) return writeState(newGame());
218
+ return normalizeState(doc.state);
219
+ }
220
+
221
+ async function snapshot(context) {
222
+ seatFor(context);
223
+ return runExclusive(lockKey, async function () {
224
+ return project(await readState());
225
+ });
226
+ }
227
+
228
+ // An act returns {state, event}: the new projection for the caller and the
229
+ // causal event for every watching Display. Both are built inside the lock,
230
+ // so seq order on the wire is the order the rules actually ran in.
231
+ async function act(context, actionId, args) {
232
+ var seat = seatFor(context);
233
+ return runExclusive(lockKey, async function () {
234
+ var state = await readState();
235
+ var previous = project(state);
236
+ var next;
237
+ if (actionId === "mark") next = mark(state, seat, args);
238
+ else if (actionId === "reset") next = reset(state, seat);
239
+ else throw new Error("Unknown Tic-Tac-Toe action '" + String(actionId) + "'.");
240
+ next.eventSeq = previous.eventSeq + 1;
241
+ await writeState(next);
242
+ var projected = project(next);
243
+ return { state: projected, event: buildEvent(seat, actionId, previous, projected) };
244
+ });
245
+ }
246
+
247
+ return { snapshot: snapshot, act: act };
248
+ }
249
+
250
+ module.exports = {
251
+ GAME_DOC_ID: GAME_DOC_ID,
252
+ SEATS: SEATS,
253
+ newGame: newGame,
254
+ normalizeState: normalizeState,
255
+ project: project,
256
+ buildEvent: buildEvent,
257
+ mark: mark,
258
+ reset: reset,
259
+ createRuntime: createRuntime,
260
+ };
@@ -0,0 +1,190 @@
1
+ // Pig rich Display element. Runs inside the sandboxed Capsule frame.
2
+ //
3
+ // This element is pure rendering over the same projection the floor renders
4
+ // and a Mate reads: it invents no meaning of its own, and its only outward
5
+ // capability is ClayCapsule.act, which lands in the same Logic pipeline as a
6
+ // floor button. Swapping this file in or out changes nothing a Mate sees.
7
+
8
+ (function () {
9
+ "use strict";
10
+
11
+ var api = window.ClayCapsule;
12
+ var state = null;
13
+ var caption = "";
14
+ var animating = null;
15
+
16
+ document.body.style.margin = "0";
17
+ document.body.style.background = "#141414";
18
+ document.body.style.fontFamily = "system-ui, sans-serif";
19
+
20
+ var root = document.createElement("div");
21
+ root.style.padding = "12px";
22
+ document.body.appendChild(root);
23
+
24
+ var captionEl = document.createElement("div");
25
+ captionEl.style.color = "#b8b8b8";
26
+ captionEl.style.fontSize = "12px";
27
+ captionEl.style.minHeight = "16px";
28
+ captionEl.style.marginBottom = "8px";
29
+ root.appendChild(captionEl);
30
+
31
+ var canvas = document.createElement("canvas");
32
+ canvas.width = 560;
33
+ canvas.height = 190;
34
+ canvas.style.width = "100%";
35
+ canvas.style.display = "block";
36
+ root.appendChild(canvas);
37
+ var ctx = canvas.getContext("2d");
38
+
39
+ var controls = document.createElement("div");
40
+ controls.style.display = "flex";
41
+ controls.style.gap = "8px";
42
+ controls.style.marginTop = "10px";
43
+ root.appendChild(controls);
44
+
45
+ function makeButton(label, action, accent) {
46
+ var button = document.createElement("button");
47
+ button.type = "button";
48
+ button.textContent = label;
49
+ button.style.flex = "1";
50
+ button.style.padding = "8px 0";
51
+ button.style.border = "0";
52
+ button.style.borderRadius = "8px";
53
+ button.style.fontSize = "13px";
54
+ button.style.cursor = "pointer";
55
+ button.style.background = accent ? "#c96f4a" : "#2c2c2c";
56
+ button.style.color = accent ? "#141414" : "#d8d8d8";
57
+ button.addEventListener("click", function () { api.act(action, {}); });
58
+ controls.appendChild(button);
59
+ return button;
60
+ }
61
+
62
+ var rollButton = makeButton("Roll", "roll", true);
63
+ var holdButton = makeButton("Hold", "hold", false);
64
+ var resetButton = makeButton("Reset", "reset", false);
65
+
66
+ function drawBar(y, label, value, target, color) {
67
+ ctx.fillStyle = "#b8b8b8";
68
+ ctx.font = "12px system-ui, sans-serif";
69
+ ctx.fillText(label + " " + value + " / " + target, 12, y - 6);
70
+ ctx.fillStyle = "#2c2c2c";
71
+ ctx.fillRect(12, y, 380, 14);
72
+ ctx.fillStyle = color;
73
+ var width = Math.max(0, Math.min(1, value / target)) * 380;
74
+ ctx.fillRect(12, y, width, 14);
75
+ }
76
+
77
+ function drawDie(face, highlight) {
78
+ var x = 430;
79
+ var y = 40;
80
+ var size = 96;
81
+ ctx.fillStyle = highlight ? "#f0e5d8" : "#d8d8d8";
82
+ ctx.beginPath();
83
+ ctx.roundRect(x, y, size, size, 14);
84
+ ctx.fill();
85
+ if (!face) return;
86
+ ctx.fillStyle = face === 1 ? "#c9564a" : "#141414";
87
+ var spots = {
88
+ 1: [[0.5, 0.5]],
89
+ 2: [[0.28, 0.28], [0.72, 0.72]],
90
+ 3: [[0.25, 0.25], [0.5, 0.5], [0.75, 0.75]],
91
+ 4: [[0.28, 0.28], [0.72, 0.28], [0.28, 0.72], [0.72, 0.72]],
92
+ 5: [[0.25, 0.25], [0.75, 0.25], [0.5, 0.5], [0.25, 0.75], [0.75, 0.75]],
93
+ 6: [[0.28, 0.22], [0.72, 0.22], [0.28, 0.5], [0.72, 0.5], [0.28, 0.78], [0.72, 0.78]],
94
+ };
95
+ var pips = spots[face] || [];
96
+ for (var i = 0; i < pips.length; i++) {
97
+ ctx.beginPath();
98
+ ctx.arc(x + pips[i][0] * size, y + pips[i][1] * size, 7, 0, Math.PI * 2);
99
+ ctx.fill();
100
+ }
101
+ }
102
+
103
+ function draw(faceOverride, highlight) {
104
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
105
+ if (!state) {
106
+ ctx.fillStyle = "#b8b8b8";
107
+ ctx.font = "13px system-ui, sans-serif";
108
+ ctx.fillText("Waiting for the game state...", 12, 30);
109
+ return;
110
+ }
111
+ var target = state.target || 100;
112
+ var scores = state.scores || {};
113
+ drawBar(36, "You", scores.user || 0, target, "#5a9e6f");
114
+ drawBar(78, "Your Mate", scores.mate || 0, target, "#6f7fc9");
115
+ ctx.fillStyle = "#d8d8d8";
116
+ ctx.font = "13px system-ui, sans-serif";
117
+ ctx.fillText(state.turnTotalText || "", 12, 122);
118
+ if (state.complete && state.winner) {
119
+ ctx.fillStyle = "#f0c96f";
120
+ ctx.font = "600 15px system-ui, sans-serif";
121
+ ctx.fillText((state.winner === "user" ? "You win!" : "Your Mate wins!"), 12, 152);
122
+ }
123
+ drawDie(faceOverride !== undefined ? faceOverride : state.lastRoll, !!highlight);
124
+ }
125
+
126
+ function syncButtons() {
127
+ var userTurn = !!(state && state.userTurn);
128
+ rollButton.disabled = !userTurn;
129
+ holdButton.disabled = !userTurn;
130
+ rollButton.style.opacity = userTurn ? "1" : "0.4";
131
+ holdButton.style.opacity = userTurn ? "1" : "0.4";
132
+ }
133
+
134
+ // Animates a decided roll: a brief pip shuffle that settles on the face the
135
+ // server already rolled. Pure presentation of a fact already in state.
136
+ function animateRoll(finalFace, next) {
137
+ if (animating) clearInterval(animating.timer);
138
+ var frames = 8;
139
+ var animation = { timer: null };
140
+ animating = animation;
141
+ animation.timer = setInterval(function () {
142
+ frames--;
143
+ if (frames <= 0) {
144
+ clearInterval(animation.timer);
145
+ if (animating === animation) animating = null;
146
+ state = next;
147
+ draw(finalFace, finalFace === 1);
148
+ syncButtons();
149
+ return;
150
+ }
151
+ draw((frames % 6) + 1, true);
152
+ }, 55);
153
+ }
154
+
155
+ api.onState = function (nextState) {
156
+ state = nextState || {};
157
+ if (!animating) {
158
+ draw();
159
+ syncButtons();
160
+ }
161
+ captionEl.textContent = caption;
162
+ };
163
+
164
+ api.onEvent = function (event) {
165
+ if (!event || !event.next) return;
166
+ var who = event.actor === "mate" ? "Your Mate" : "You";
167
+ var next = event.next;
168
+ if (event.action === "roll") {
169
+ caption = next.lastRoll === 1
170
+ ? who + " rolled a 1 and lost the turn total."
171
+ : who + " rolled a " + next.lastRoll + ".";
172
+ captionEl.textContent = caption;
173
+ animateRoll(next.lastRoll, next);
174
+ return;
175
+ }
176
+ if (event.action === "hold") {
177
+ var banked = (event.previous && event.previous.turnTotal) || 0;
178
+ caption = who + " held and banked " + banked + " point(s).";
179
+ } else if (event.action === "reset") {
180
+ caption = who + " started a new game.";
181
+ }
182
+ state = next;
183
+ captionEl.textContent = caption;
184
+ draw();
185
+ syncButtons();
186
+ };
187
+
188
+ draw();
189
+ syncButtons();
190
+ })();
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "pig",
3
+ "name": "Pig",
4
+ "runtime": "server",
5
+ "lucideIcon": "sparkles",
6
+ "description": "Two-player push-your-luck dice game shared by the user and one Mate.",
7
+ "useWhen": "Use when the user wants to play Pig, take a turn, or hear how the current game stands.",
8
+ "skills": "# Pig\n\nA two-seat push-your-luck dice game. The seats are `user` and `mate`; the person holds the user seat and you hold the mate seat. All randomness, turn order, and scoring are enforced server-side by the Capsule's Logic; you cannot roll a die yourself, and neither can the user.\n\n## Rules\n\n- Seats alternate turns. On your turn you may roll as often as you like.\n- Each roll adds its face value to the turn total. Rolling a 1 loses the entire turn total and passes play to the other seat.\n- Holding banks the turn total into your score and passes play.\n- The first seat to bank at least 100 points wins. A finished game is frozen until it is reset.\n- Only the user may reset a game that is still in progress. You may reset once the game is complete.\n\n## How to play\n\n1. `clay_tool_snapshot` with toolId `pig` to read the current state: `turn`, `scores.user`, `scores.mate`, `turnTotal`, `status`, `winner`, and `recentRolls`, which lists recent rolls only and never holds or wins.\n2. Act only when `turn` is `mate`. `clay_tool_act` with actionId `roll`, `hold`, or `reset`. Arguments are not used.\n3. An out-of-turn or post-game call is refused by Logic with an explanatory error. Read the snapshot again rather than retrying blindly.\n4. Every act returns the complete new state, so a snapshot immediately after an act is redundant.\n\n## Playing well\n\nThe interesting part of this Capsule is the judgement call, not the mechanics: decide whether to roll again or hold, and say why in your own words before you act. Typical play holds at a turn total near 20, but the score gap matters more than the rule of thumb, so explain the trade-off you are taking. Never claim a roll result before the act returns it."
9
+ }
@@ -0,0 +1,46 @@
1
+ {
2
+ "type": "stack",
3
+ "props": { "gap": "md" },
4
+ "children": [
5
+ {
6
+ "type": "chart",
7
+ "bind": "userScoreSeries",
8
+ "props": { "label": "Your score", "kind": "progress", "categoryKey": "seat", "valueKey": "value", "max": 100 }
9
+ },
10
+ {
11
+ "type": "chart",
12
+ "bind": "mateScoreSeries",
13
+ "props": { "label": "Your Mate's score", "kind": "progress", "categoryKey": "seat", "valueKey": "value", "max": 100 }
14
+ },
15
+ { "type": "text", "props": { "text": { "$bind": "$state.turnTotalText", "fallback": "Turn total 0 of 100." }, "role": "output" } },
16
+ {
17
+ "type": "row",
18
+ "props": { "gap": "sm", "wrap": true },
19
+ "children": [
20
+ {
21
+ "type": "button",
22
+ "action": "roll",
23
+ "when": { "all": [{ "equals": { "path": "$state.status", "value": "playing" } }, { "equals": { "path": "$state.turn", "value": "user" } }] },
24
+ "props": { "label": "Roll", "variant": "primary" },
25
+ "else": { "type": "button", "action": "roll", "props": { "label": "Roll", "variant": "primary", "disabled": true } }
26
+ },
27
+ {
28
+ "type": "button",
29
+ "action": "hold",
30
+ "when": { "all": [{ "equals": { "path": "$state.status", "value": "playing" } }, { "equals": { "path": "$state.turn", "value": "user" } }] },
31
+ "props": { "label": "Hold", "variant": "secondary" },
32
+ "else": { "type": "button", "action": "hold", "props": { "label": "Hold", "variant": "secondary", "disabled": true } }
33
+ },
34
+ { "type": "button", "action": "reset", "props": { "label": "Reset", "variant": "ghost" } }
35
+ ]
36
+ },
37
+ {
38
+ "type": "list",
39
+ "bind": "recentRolls",
40
+ "props": { "variant": "divided", "gap": "xs" },
41
+ "children": [
42
+ { "type": "text", "props": { "text": "$item.text", "role": "body" } }
43
+ ]
44
+ }
45
+ ]
46
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "tictactoe",
3
+ "name": "Tic-Tac-Toe",
4
+ "runtime": "server",
5
+ "lucideIcon": "x",
6
+ "description": "Two-player tic-tac-toe on a 3x3 grid shared by the user and one Mate.",
7
+ "useWhen": "Use when the user wants to play tic-tac-toe, take a turn on the grid, or hear how the current game stands.",
8
+ "skills": "# Tic-Tac-Toe\n\nA two-seat game on a 3x3 grid. The seats are `user` (X) and `mate` (O); the person holds the user seat and you hold the mate seat. Turn order, cell ownership, and win detection are enforced server-side by the Capsule's Logic; you cannot mark a cell for the user, and the user cannot mark one for you.\n\n## Rules\n\n- The user (X) moves first in a fresh game, and seats alternate marks.\n- Cells are numbered 0 to 8, left to right, top to bottom: 0 1 2 on the top row, 3 4 5 in the middle, 6 7 8 on the bottom.\n- Three matching marks in a row, column, or diagonal win. A full board with no line is a draw.\n- A finished game is frozen until it is reset. Only the user may reset a game that is still in progress. You may reset once the game is complete.\n\n## How to play\n\n1. `clay_tool_snapshot` with toolId `tictactoe` to read the current state first: `turn`, `status`, `winner`, `statusText`, and the board as `cell0` through `cell8` (each `\"X\"`, `\"O\"`, or empty).\n2. Act only when `turn` is `mate`. `clay_tool_act` with actionId `mark` and args `{\"cell\": N}` where N is 0-8, or actionId `reset` with no args.\n3. An occupied cell, an out-of-turn move, or a post-game move is refused by Logic with an explanatory error. Read the snapshot again rather than retrying blindly.\n4. Every act returns the complete new state, so a snapshot immediately after an act is redundant.\n\n## Playing well\n\nThe interesting part of this Capsule is the judgement call, not the mechanics: block a line the user is about to complete, take a winning line when you have one, and say what you see in your own words before you act. Never claim the outcome of a move before the act returns it."
9
+ }