clay-server 4.0.0-beta.14 → 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.
- package/lib/capsule-display-floor.js +51 -0
- package/lib/capsule-frame-server.js +229 -0
- package/lib/capsule-pig-logic.js +268 -0
- package/lib/capsule-server-runtimes.js +41 -0
- package/lib/capsule-tictactoe-logic.js +260 -0
- package/lib/capsules/pig/display.js +190 -0
- package/lib/capsules/pig/manifest.json +9 -0
- package/lib/capsules/pig/ui.json +46 -0
- package/lib/capsules/tictactoe/manifest.json +9 -0
- package/lib/capsules/tictactoe/ui.json +203 -0
- package/lib/project-capsule-catalog.js +9 -1
- package/lib/project-connection.js +1 -1
- package/lib/project-pair-lifecycle.js +4 -8
- package/lib/project-session-pair.js +29 -39
- package/lib/project-worker-proposal.js +203 -72
- package/lib/project.js +5 -0
- package/lib/public/css/capsule-ui.css +18 -0
- package/lib/public/css/home-session-actions.css +66 -0
- package/lib/public/css/home-sidebar.css +66 -0
- package/lib/public/css/mobile-nav.css +80 -0
- package/lib/public/css/sidebar.css +92 -0
- package/lib/public/css/worker-proposal.css +34 -1
- package/lib/public/modules/app-messages.js +14 -1
- package/lib/public/modules/home-conversations-sheet.js +102 -48
- package/lib/public/modules/home-session-actions.js +6 -0
- package/lib/public/modules/home-sidebar-chat-list.js +101 -41
- package/lib/public/modules/home-tool-frame.js +165 -0
- package/lib/public/modules/home-tools.js +129 -1
- package/lib/public/modules/session-hierarchy.js +54 -0
- package/lib/public/modules/sidebar-mobile.js +35 -6
- package/lib/public/modules/sidebar-session-hierarchy.js +206 -0
- package/lib/public/modules/sidebar-sessions.js +49 -7
- package/lib/public/modules/worker-proposal-state.js +16 -0
- package/lib/public/modules/worker-proposal.js +49 -12
- package/lib/sdk-bridge.js +8 -8
- package/lib/server-home-chat.js +12 -1
- package/lib/server-tools.js +97 -9
- package/lib/server.js +3 -0
- package/lib/session-driver-eligibility.js +8 -0
- package/lib/session-pair-factory.js +27 -15
- package/lib/session-pair-mcp-server.js +5 -9
- package/lib/session-pair-prompts.js +15 -15
- package/lib/session-provenance.js +119 -0
- package/lib/sessions.js +40 -2
- package/lib/tools-registry.js +44 -10
- package/lib/ws-schema.js +8 -3
- package/package.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The mandatory declarative floor element of a Capsule's Display set.
|
|
2
|
+
//
|
|
3
|
+
// A Capsule is Logic, Skills, and Display. Display carries no packaging duty
|
|
4
|
+
// towards an agent (no Mate ever reads it), but it does carry the human's
|
|
5
|
+
// sovereignty: every Capsule must ship one element the host can always render,
|
|
6
|
+
// so the Capsule stays fully operable by hand with no AI in the path. That
|
|
7
|
+
// element is the validated declarative tree, and it is the floor.
|
|
8
|
+
//
|
|
9
|
+
// Two gates follow from that, and they are gates rather than aspirations:
|
|
10
|
+
// 1. Registration refuses a Capsule whose Display set lacks the floor.
|
|
11
|
+
// 2. Skills go dark when the floor does. A Capsule whose floor is
|
|
12
|
+
// unavailable leaves the catalog and the control surface a Mate sees, at
|
|
13
|
+
// the same moment the human loses it.
|
|
14
|
+
//
|
|
15
|
+
// The only question either gate asks is the one tool-ui-spec answers: does the
|
|
16
|
+
// Capsule have a declarative tree, and does that tree validate? A tree that is
|
|
17
|
+
// merely sparse is still a floor. Nothing here reads a stored verdict, because
|
|
18
|
+
// a stored verdict is a claim rather than the Display itself; the gate always
|
|
19
|
+
// validates the actual tree.
|
|
20
|
+
|
|
21
|
+
var toolUiSpec = require("./tool-ui-spec");
|
|
22
|
+
|
|
23
|
+
var FLOOR_ELEMENT = "declarative";
|
|
24
|
+
|
|
25
|
+
// Fails closed: a scan error, a missing ID, a missing tree, or a tree that
|
|
26
|
+
// does not validate all mean the same thing to a Mate, which is no Capsule.
|
|
27
|
+
function hasUsableFloor(manifest, uiTree) {
|
|
28
|
+
if (!manifest || manifest.error || !manifest.id) return false;
|
|
29
|
+
var tree = uiTree === undefined ? manifest.uiTree : uiTree;
|
|
30
|
+
if (tree === undefined || tree === null) return false;
|
|
31
|
+
try {
|
|
32
|
+
toolUiSpec.validateUiTreeForManifest(tree, manifest);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function assertFloor(uiTree, manifest) {
|
|
40
|
+
if (uiTree === undefined || uiTree === null) {
|
|
41
|
+
throw new Error("Capsule Display must include the declarative floor element.");
|
|
42
|
+
}
|
|
43
|
+
toolUiSpec.validateUiTreeForManifest(uiTree, manifest);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = {
|
|
48
|
+
FLOOR_ELEMENT: FLOOR_ELEMENT,
|
|
49
|
+
hasUsableFloor: hasUsableFloor,
|
|
50
|
+
assertFloor: assertFloor,
|
|
51
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Isolated host for a Capsule's opt-in rich Display element.
|
|
2
|
+
//
|
|
3
|
+
// The rich element is additive Display: arbitrary rendering (canvas, WebGL)
|
|
4
|
+
// with zero authority. It runs in an iframe that is sandboxed with
|
|
5
|
+
// allow-scripts only and served from this dedicated listener, a distinct
|
|
6
|
+
// origin from the app, so nothing of Clay's DOM, cookies, or storage is
|
|
7
|
+
// reachable. Its one outward edge is postMessage to its embedder, and the
|
|
8
|
+
// only thing it can ask for is the same validated act a human click requests.
|
|
9
|
+
//
|
|
10
|
+
// The frame document carries its own CSP, strictly tighter than the app
|
|
11
|
+
// policy: default-src 'none' with a per-response script nonce. There is no
|
|
12
|
+
// connect-src at all, so the frame cannot fetch, beacon, or open sockets; a
|
|
13
|
+
// rich element ships self-contained. The nonce authorizes exactly two
|
|
14
|
+
// scripts: the inline bridge below and the Capsule's own display.js.
|
|
15
|
+
//
|
|
16
|
+
// This origin serves no cookies and knows no sessions. A frame URL is gated
|
|
17
|
+
// by a short-lived token issued over the user's authenticated WebSocket, and
|
|
18
|
+
// each token admits one shell fetch and one display.js fetch. Whatever state
|
|
19
|
+
// the host later pushes into the frame is the entire exfiltration surface,
|
|
20
|
+
// chosen per element, exactly like a snapshot projection is chosen per caller.
|
|
21
|
+
|
|
22
|
+
var crypto = require("crypto");
|
|
23
|
+
var fs = require("fs");
|
|
24
|
+
var http = require("http");
|
|
25
|
+
var https = require("https");
|
|
26
|
+
var path = require("path");
|
|
27
|
+
var toolsRegistry = require("./tools-registry");
|
|
28
|
+
|
|
29
|
+
var TOKEN_TTL_MS = 60000;
|
|
30
|
+
var DISPLAY_FILE = "display.js";
|
|
31
|
+
var MAX_DISPLAY_BYTES = 512 * 1024;
|
|
32
|
+
|
|
33
|
+
function createCapsuleFrameServer(opts) {
|
|
34
|
+
opts = opts || {};
|
|
35
|
+
var tlsOptions = opts.tlsOptions || null;
|
|
36
|
+
var tokens = Object.create(null);
|
|
37
|
+
var server = null;
|
|
38
|
+
var listening = null;
|
|
39
|
+
|
|
40
|
+
function pruneTokens(now) {
|
|
41
|
+
var keys = Object.keys(tokens);
|
|
42
|
+
for (var i = 0; i < keys.length; i++) {
|
|
43
|
+
if (tokens[keys[i]].expiresAt <= now) delete tokens[keys[i]];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function displayPathFor(ctx, toolId) {
|
|
48
|
+
toolsRegistry.validateToolId(toolId);
|
|
49
|
+
return path.join(toolsRegistry.resolveToolsRoot(ctx), toolId, DISPLAY_FILE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasRichDisplay(ctx, toolId) {
|
|
53
|
+
try {
|
|
54
|
+
return fs.existsSync(displayPathFor(ctx, toolId));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ensureListening() {
|
|
61
|
+
if (listening) return listening;
|
|
62
|
+
server = tlsOptions ? https.createServer(tlsOptions, handleRequest) : http.createServer(handleRequest);
|
|
63
|
+
listening = new Promise(function (resolve, reject) {
|
|
64
|
+
server.once("error", reject);
|
|
65
|
+
server.listen(opts.port || 0, function () {
|
|
66
|
+
resolve(server.address().port);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
return listening;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Issues the one-time frame URL for a Capsule that ships a rich element.
|
|
73
|
+
// Callable only from the authenticated WebSocket side, which is what binds
|
|
74
|
+
// the anonymous frame origin to a specific user's tools root.
|
|
75
|
+
function issueFrameUrl(ctx, toolId) {
|
|
76
|
+
if (!hasRichDisplay(ctx, toolId)) {
|
|
77
|
+
return Promise.reject(new Error("This Capsule ships no rich Display element."));
|
|
78
|
+
}
|
|
79
|
+
return ensureListening().then(function (port) {
|
|
80
|
+
pruneTokens(Date.now());
|
|
81
|
+
var token = crypto.randomBytes(24).toString("hex");
|
|
82
|
+
tokens[token] = {
|
|
83
|
+
ctx: { userId: ctx.userId, multiUser: ctx.multiUser, linuxUser: ctx.linuxUser },
|
|
84
|
+
toolId: toolId,
|
|
85
|
+
expiresAt: Date.now() + TOKEN_TTL_MS,
|
|
86
|
+
shell: true,
|
|
87
|
+
display: true,
|
|
88
|
+
};
|
|
89
|
+
return { port: port, path: "/capsule/?t=" + token, secure: !!tlsOptions };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function consumeToken(token, use) {
|
|
94
|
+
if (typeof token !== "string" || !token) return null;
|
|
95
|
+
var entry = tokens[token];
|
|
96
|
+
if (!entry) return null;
|
|
97
|
+
if (entry.expiresAt <= Date.now()) {
|
|
98
|
+
delete tokens[token];
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
if (!entry[use]) return null;
|
|
102
|
+
entry[use] = false;
|
|
103
|
+
if (!entry.shell && !entry.display) delete tokens[token];
|
|
104
|
+
return entry;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function deny(res, code) {
|
|
108
|
+
res.writeHead(code, { "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff" });
|
|
109
|
+
res.end(code === 404 ? "Not found" : "Forbidden");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The in-frame half of the bridge. Its only capabilities are receiving
|
|
113
|
+
// state/event pushes from the embedder and asking the embedder for an act.
|
|
114
|
+
// The embedder routes that request into the same pipeline a floor button
|
|
115
|
+
// uses; nothing here can mutate anything directly.
|
|
116
|
+
function bridgeSource() {
|
|
117
|
+
return [
|
|
118
|
+
"(function () {",
|
|
119
|
+
" \"use strict\";",
|
|
120
|
+
" var api = {",
|
|
121
|
+
" onState: null,",
|
|
122
|
+
" onEvent: null,",
|
|
123
|
+
" act: function (actionId, args) {",
|
|
124
|
+
" window.parent.postMessage({ clayCapsuleFrame: 1, type: \"act\", actionId: String(actionId), args: args || {} }, \"*\");",
|
|
125
|
+
" },",
|
|
126
|
+
" };",
|
|
127
|
+
" window.ClayCapsule = api;",
|
|
128
|
+
" window.addEventListener(\"message\", function (event) {",
|
|
129
|
+
" if (event.source !== window.parent) return;",
|
|
130
|
+
" var data = event.data;",
|
|
131
|
+
" if (!data || data.clayCapsuleFrame !== 1) return;",
|
|
132
|
+
" if (data.type === \"state\" && typeof api.onState === \"function\") api.onState(data.state || {});",
|
|
133
|
+
" else if (data.type === \"event\" && typeof api.onEvent === \"function\") api.onEvent(data.event || null);",
|
|
134
|
+
" });",
|
|
135
|
+
" window.addEventListener(\"load\", function () {",
|
|
136
|
+
" window.parent.postMessage({ clayCapsuleFrame: 1, type: \"ready\" }, \"*\");",
|
|
137
|
+
" });",
|
|
138
|
+
"})();",
|
|
139
|
+
].join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function shellHtml(nonce, token) {
|
|
143
|
+
return [
|
|
144
|
+
"<!doctype html>",
|
|
145
|
+
"<html>",
|
|
146
|
+
"<head>",
|
|
147
|
+
"<meta charset=\"utf-8\">",
|
|
148
|
+
"<title>Capsule Display</title>",
|
|
149
|
+
"</head>",
|
|
150
|
+
"<body>",
|
|
151
|
+
"<script nonce=\"" + nonce + "\">" + bridgeSource() + "</script>",
|
|
152
|
+
"<script nonce=\"" + nonce + "\" src=\"/capsule/display.js?t=" + token + "\"></script>",
|
|
153
|
+
"</body>",
|
|
154
|
+
"</html>",
|
|
155
|
+
].join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function handleRequest(req, res) {
|
|
159
|
+
if (req.method !== "GET") return deny(res, 404);
|
|
160
|
+
var parsed;
|
|
161
|
+
try {
|
|
162
|
+
parsed = new URL(req.url, "http://frame.invalid");
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return deny(res, 404);
|
|
165
|
+
}
|
|
166
|
+
var token = parsed.searchParams.get("t");
|
|
167
|
+
if (parsed.pathname === "/capsule/" || parsed.pathname === "/capsule") {
|
|
168
|
+
var shellEntry = consumeToken(token, "shell");
|
|
169
|
+
if (!shellEntry) return deny(res, 403);
|
|
170
|
+
var nonce = crypto.randomBytes(16).toString("base64");
|
|
171
|
+
res.writeHead(200, {
|
|
172
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
173
|
+
// No connect-src, no default fallback: the frame cannot reach the
|
|
174
|
+
// network at all, and only the two nonced scripts may run. This is
|
|
175
|
+
// deliberately tighter than the app policy in every direction.
|
|
176
|
+
"Content-Security-Policy": "default-src 'none'; script-src 'nonce-" + nonce + "'; base-uri 'none'; form-action 'none'",
|
|
177
|
+
"X-Content-Type-Options": "nosniff",
|
|
178
|
+
"Referrer-Policy": "no-referrer",
|
|
179
|
+
"Cache-Control": "no-store",
|
|
180
|
+
});
|
|
181
|
+
res.end(shellHtml(nonce, token));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (parsed.pathname === "/capsule/display.js") {
|
|
185
|
+
var displayEntry = consumeToken(token, "display");
|
|
186
|
+
if (!displayEntry) return deny(res, 403);
|
|
187
|
+
var source;
|
|
188
|
+
try {
|
|
189
|
+
var filePath = displayPathFor(displayEntry.ctx, displayEntry.toolId);
|
|
190
|
+
if (fs.statSync(filePath).size > MAX_DISPLAY_BYTES) return deny(res, 403);
|
|
191
|
+
source = fs.readFileSync(filePath, "utf8");
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return deny(res, 404);
|
|
194
|
+
}
|
|
195
|
+
res.writeHead(200, {
|
|
196
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
197
|
+
"X-Content-Type-Options": "nosniff",
|
|
198
|
+
"Referrer-Policy": "no-referrer",
|
|
199
|
+
"Cache-Control": "no-store",
|
|
200
|
+
});
|
|
201
|
+
res.end(source);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
return deny(res, 404);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function close() {
|
|
208
|
+
if (!server) return Promise.resolve();
|
|
209
|
+
var closing = server;
|
|
210
|
+
server = null;
|
|
211
|
+
listening = null;
|
|
212
|
+
return new Promise(function (resolve) {
|
|
213
|
+
closing.close(function () { resolve(); });
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
issueFrameUrl: issueFrameUrl,
|
|
219
|
+
hasRichDisplay: hasRichDisplay,
|
|
220
|
+
ensureListening: ensureListening,
|
|
221
|
+
close: close,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = {
|
|
226
|
+
DISPLAY_FILE: DISPLAY_FILE,
|
|
227
|
+
TOKEN_TTL_MS: TOKEN_TTL_MS,
|
|
228
|
+
createCapsuleFrameServer: createCapsuleFrameServer,
|
|
229
|
+
};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Server-side Logic for the Pig validation 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: the die is rolled here, the
|
|
5
|
+
// turn rules are enforced here, and the game record lives in the Capsule's own
|
|
6
|
+
// datastore. A human button click and a Mate clay_tool_act call arrive at the
|
|
7
|
+
// same act pipeline with the same rules, so neither party can break a rule the
|
|
8
|
+
// other is bound by, and neither sees state the other cannot.
|
|
9
|
+
//
|
|
10
|
+
// The two seats are "user" and "mate". The server resolves its own actor before
|
|
11
|
+
// calling in, and the human actor maps onto the user seat; seat is never read
|
|
12
|
+
// from caller-supplied text.
|
|
13
|
+
//
|
|
14
|
+
// Rules: seats alternate turns. On your turn you may roll as often as you like;
|
|
15
|
+
// each roll adds to the turn total, but rolling a 1 loses the whole turn total
|
|
16
|
+
// and passes play. Holding banks the turn total into your score, and the first
|
|
17
|
+
// seat to bank at least 100 points wins. A finished game is frozen until
|
|
18
|
+
// someone resets it; only the user may reset a game in progress, because the
|
|
19
|
+
// Capsule belongs to the human, not to the Mate.
|
|
20
|
+
|
|
21
|
+
var crypto = require("crypto");
|
|
22
|
+
|
|
23
|
+
var GAME_DOC_ID = "game";
|
|
24
|
+
var TARGET_SCORE = 100;
|
|
25
|
+
var BUST_FACE = 1;
|
|
26
|
+
var USER = "user";
|
|
27
|
+
var MATE = "mate";
|
|
28
|
+
var SEATS = [USER, MATE];
|
|
29
|
+
var SEAT_LABELS = { user: "You", mate: "Your Mate" };
|
|
30
|
+
var MAX_RECENT_ROLLS = 12;
|
|
31
|
+
|
|
32
|
+
// Every read-modify-write for one stored game runs to completion before the
|
|
33
|
+
// next one starts. Two acts that arrive together would otherwise both validate
|
|
34
|
+
// against the same turn, and the second write would silently discard the first.
|
|
35
|
+
var gameLocks = Object.create(null);
|
|
36
|
+
|
|
37
|
+
function runExclusive(key, operation) {
|
|
38
|
+
var previous = gameLocks[key] || Promise.resolve();
|
|
39
|
+
var settled = previous.then(operation, operation);
|
|
40
|
+
gameLocks[key] = settled.then(function () {}, function () {});
|
|
41
|
+
return settled;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function secureRollDie() {
|
|
45
|
+
return crypto.randomInt(1, 7);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function otherSeat(seat) {
|
|
49
|
+
return seat === USER ? MATE : USER;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function newGame() {
|
|
53
|
+
return {
|
|
54
|
+
status: "playing",
|
|
55
|
+
turn: USER,
|
|
56
|
+
scores: { user: 0, mate: 0 },
|
|
57
|
+
turnTotal: 0,
|
|
58
|
+
lastRoll: null,
|
|
59
|
+
lastActor: null,
|
|
60
|
+
winner: null,
|
|
61
|
+
target: TARGET_SCORE,
|
|
62
|
+
sequence: 0,
|
|
63
|
+
eventSeq: 0,
|
|
64
|
+
recentRolls: [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function safeScore(value) {
|
|
69
|
+
return Number.isInteger(value) && value >= 0 && value <= 10000 ? value : 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Counters are ordering, not points: they only ever grow, so they carry no
|
|
73
|
+
// upper bound. eventSeq in particular must survive a reset, because a Display
|
|
74
|
+
// that saw event N must treat anything at or below N as already rendered.
|
|
75
|
+
function safeCounter(value) {
|
|
76
|
+
return Number.isInteger(value) && value >= 0 ? value : 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A stored game is data, not a promise about shape. Anything unreadable is
|
|
80
|
+
// replaced by a fresh game rather than trusted.
|
|
81
|
+
function normalizeState(stored) {
|
|
82
|
+
if (!stored || typeof stored !== "object" || Array.isArray(stored)) return newGame();
|
|
83
|
+
if (SEATS.indexOf(stored.turn) === -1) return newGame();
|
|
84
|
+
if (stored.status !== "playing" && stored.status !== "complete") return newGame();
|
|
85
|
+
var scores = stored.scores && typeof stored.scores === "object" ? stored.scores : {};
|
|
86
|
+
var rolls = Array.isArray(stored.recentRolls) ? stored.recentRolls.slice(-MAX_RECENT_ROLLS) : [];
|
|
87
|
+
return {
|
|
88
|
+
status: stored.status,
|
|
89
|
+
turn: stored.turn,
|
|
90
|
+
scores: { user: safeScore(scores.user), mate: safeScore(scores.mate) },
|
|
91
|
+
turnTotal: safeScore(stored.turnTotal),
|
|
92
|
+
lastRoll: Number.isInteger(stored.lastRoll) ? stored.lastRoll : null,
|
|
93
|
+
lastActor: SEATS.indexOf(stored.lastActor) !== -1 ? stored.lastActor : null,
|
|
94
|
+
winner: SEATS.indexOf(stored.winner) !== -1 ? stored.winner : null,
|
|
95
|
+
target: TARGET_SCORE,
|
|
96
|
+
sequence: safeCounter(stored.sequence),
|
|
97
|
+
eventSeq: safeCounter(stored.eventSeq),
|
|
98
|
+
recentRolls: rolls.filter(function (entry) {
|
|
99
|
+
return entry && typeof entry.text === "string" && Number.isInteger(entry.face);
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// recentRolls holds rolls and only rolls, including a bust. Banking and winning
|
|
105
|
+
// are turn outcomes rather than rolls, so they never appear here.
|
|
106
|
+
function appendRoll(state, seat, face, text) {
|
|
107
|
+
var next = state.sequence + 1;
|
|
108
|
+
state.sequence = next;
|
|
109
|
+
state.recentRolls = state.recentRolls
|
|
110
|
+
.concat([{ id: "roll-" + next, seat: seat, face: face, text: text }])
|
|
111
|
+
.slice(-MAX_RECENT_ROLLS);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function requirePlayableTurn(state, seat) {
|
|
115
|
+
if (state.status !== "playing") {
|
|
116
|
+
throw new Error("This game is already over. Reset it to play again.");
|
|
117
|
+
}
|
|
118
|
+
if (state.turn !== seat) {
|
|
119
|
+
throw new Error("This move is out of turn: " + SEAT_LABELS[state.turn] + " to play.");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function roll(state, seat, rollDie) {
|
|
124
|
+
requirePlayableTurn(state, seat);
|
|
125
|
+
var face = rollDie();
|
|
126
|
+
if (!Number.isInteger(face) || face < 1 || face > 6) throw new Error("The die produced an invalid face.");
|
|
127
|
+
state.lastRoll = face;
|
|
128
|
+
state.lastActor = seat;
|
|
129
|
+
if (face === BUST_FACE) {
|
|
130
|
+
appendRoll(state, seat, face, SEAT_LABELS[seat] + " rolled a 1 and lost " + state.turnTotal + " point(s).");
|
|
131
|
+
state.turnTotal = 0;
|
|
132
|
+
state.turn = otherSeat(seat);
|
|
133
|
+
return state;
|
|
134
|
+
}
|
|
135
|
+
state.turnTotal += face;
|
|
136
|
+
appendRoll(state, seat, face, SEAT_LABELS[seat] + " rolled a " + face + " for a turn total of " + state.turnTotal + ".");
|
|
137
|
+
return state;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hold(state, seat) {
|
|
141
|
+
requirePlayableTurn(state, seat);
|
|
142
|
+
var banked = state.turnTotal;
|
|
143
|
+
state.scores[seat] += banked;
|
|
144
|
+
state.lastActor = seat;
|
|
145
|
+
state.turnTotal = 0;
|
|
146
|
+
if (state.scores[seat] >= state.target) {
|
|
147
|
+
state.status = "complete";
|
|
148
|
+
state.winner = seat;
|
|
149
|
+
return state;
|
|
150
|
+
}
|
|
151
|
+
state.turn = otherSeat(seat);
|
|
152
|
+
return state;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function reset(state, seat) {
|
|
156
|
+
if (state.status === "playing" && seat !== USER) {
|
|
157
|
+
throw new Error("Only the user may reset a game that is still in progress.");
|
|
158
|
+
}
|
|
159
|
+
return newGame();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The projection a Display renders and a Mate reads. It adds no meaning that is
|
|
163
|
+
// absent from state: every derived field is a restatement of the fields above.
|
|
164
|
+
// The score series exist because a progress chart binds to a collection.
|
|
165
|
+
function project(state) {
|
|
166
|
+
var complete = state.status === "complete";
|
|
167
|
+
var userTurn = !complete && state.turn === USER;
|
|
168
|
+
return {
|
|
169
|
+
status: state.status,
|
|
170
|
+
turn: state.turn,
|
|
171
|
+
scores: { user: state.scores.user, mate: state.scores.mate },
|
|
172
|
+
userScoreSeries: [{ seat: SEAT_LABELS.user, value: state.scores.user }],
|
|
173
|
+
mateScoreSeries: [{ seat: SEAT_LABELS.mate, value: state.scores.mate }],
|
|
174
|
+
turnTotal: state.turnTotal,
|
|
175
|
+
turnTotalText: "Turn total " + state.turnTotal + " of " + state.target + ", " + SEAT_LABELS[state.turn] + " to play.",
|
|
176
|
+
lastRoll: state.lastRoll,
|
|
177
|
+
lastActor: state.lastActor,
|
|
178
|
+
winner: state.winner,
|
|
179
|
+
target: state.target,
|
|
180
|
+
recentRolls: state.recentRolls,
|
|
181
|
+
complete: complete,
|
|
182
|
+
userTurn: userTurn,
|
|
183
|
+
eventSeq: state.eventSeq,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Causality for the live Display: {actor, action, previous, next} plus a
|
|
188
|
+
// monotonic seq. Sending only the new state would make a Mate's move teleport
|
|
189
|
+
// onto the human's screen; the event lets the Display replay and attribute it.
|
|
190
|
+
function buildEvent(seat, actionId, previous, next) {
|
|
191
|
+
return {
|
|
192
|
+
seq: next.eventSeq,
|
|
193
|
+
actor: seat,
|
|
194
|
+
action: actionId,
|
|
195
|
+
previous: previous,
|
|
196
|
+
next: next,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function seatFor(context) {
|
|
201
|
+
var actor = context && context.actor;
|
|
202
|
+
if (actor === "human" || actor === USER) return USER;
|
|
203
|
+
if (actor === MATE) return MATE;
|
|
204
|
+
throw new Error("The Capsule caller seat could not be determined.");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// One act pipeline. The human surface and the Mate MCP surface both land here.
|
|
208
|
+
function createRuntime(options) {
|
|
209
|
+
options = options || {};
|
|
210
|
+
var storage = options.storage;
|
|
211
|
+
var rollDie = typeof options.rollDie === "function" ? options.rollDie : secureRollDie;
|
|
212
|
+
var lockKey = options.lockKey || "pig";
|
|
213
|
+
if (!storage) throw new Error("The Pig Capsule requires its datastore.");
|
|
214
|
+
|
|
215
|
+
async function writeState(state) {
|
|
216
|
+
await storage.put({ _id: GAME_DOC_ID, state: state });
|
|
217
|
+
return state;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function readState() {
|
|
221
|
+
var doc = await storage.get(GAME_DOC_ID);
|
|
222
|
+
if (!doc) return writeState(newGame());
|
|
223
|
+
return normalizeState(doc.state);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function snapshot(context) {
|
|
227
|
+
seatFor(context);
|
|
228
|
+
return runExclusive(lockKey, async function () {
|
|
229
|
+
return project(await readState());
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// An act returns {state, event}: the new projection for the caller and the
|
|
234
|
+
// causal event for every watching Display. Both are built inside the lock,
|
|
235
|
+
// so seq order on the wire is the order the rules actually ran in.
|
|
236
|
+
async function act(context, actionId, args) {
|
|
237
|
+
var seat = seatFor(context);
|
|
238
|
+
return runExclusive(lockKey, async function () {
|
|
239
|
+
var state = await readState();
|
|
240
|
+
var previous = project(state);
|
|
241
|
+
var next;
|
|
242
|
+
if (actionId === "roll") next = roll(state, seat, rollDie);
|
|
243
|
+
else if (actionId === "hold") next = hold(state, seat);
|
|
244
|
+
else if (actionId === "reset") next = reset(state, seat);
|
|
245
|
+
else throw new Error("Unknown Pig action '" + String(actionId) + "'.");
|
|
246
|
+
next.eventSeq = previous.eventSeq + 1;
|
|
247
|
+
await writeState(next);
|
|
248
|
+
var projected = project(next);
|
|
249
|
+
return { state: projected, event: buildEvent(seat, actionId, previous, projected) };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { snapshot: snapshot, act: act };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
GAME_DOC_ID: GAME_DOC_ID,
|
|
258
|
+
TARGET_SCORE: TARGET_SCORE,
|
|
259
|
+
SEATS: SEATS,
|
|
260
|
+
newGame: newGame,
|
|
261
|
+
normalizeState: normalizeState,
|
|
262
|
+
project: project,
|
|
263
|
+
buildEvent: buildEvent,
|
|
264
|
+
roll: roll,
|
|
265
|
+
hold: hold,
|
|
266
|
+
reset: reset,
|
|
267
|
+
createRuntime: createRuntime,
|
|
268
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Trusted server-runtime Logic for shipped Capsules.
|
|
2
|
+
//
|
|
3
|
+
// A server Capsule's Logic runs in this process rather than in a browser
|
|
4
|
+
// worker, so it stays available with no home screen open and owns randomness
|
|
5
|
+
// and hidden state the client must never hold. Only Capsules shipped in
|
|
6
|
+
// lib/capsules/ can claim the server runtime (see tools-registry
|
|
7
|
+
// isTrustedServerRuntime), and only IDs listed here resolve to real Logic.
|
|
8
|
+
//
|
|
9
|
+
// A runtime is created per call. State lives in the Capsule's datastore, never
|
|
10
|
+
// in this module, so nothing here is shared between users.
|
|
11
|
+
|
|
12
|
+
var path = require("path");
|
|
13
|
+
var toolStorage = require("./tool-storage");
|
|
14
|
+
var toolsRegistry = require("./tools-registry");
|
|
15
|
+
var pigLogic = require("./capsule-pig-logic");
|
|
16
|
+
var tictactoeLogic = require("./capsule-tictactoe-logic");
|
|
17
|
+
|
|
18
|
+
var RUNTIME_FACTORIES = {
|
|
19
|
+
pig: pigLogic.createRuntime,
|
|
20
|
+
tictactoe: tictactoeLogic.createRuntime,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function hasRuntime(toolId) {
|
|
24
|
+
return Object.prototype.hasOwnProperty.call(RUNTIME_FACTORIES, toolId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createRuntime(toolId, ctx, options) {
|
|
28
|
+
if (!hasRuntime(toolId)) return null;
|
|
29
|
+
var settings = Object.assign({}, options || {});
|
|
30
|
+
if (!settings.storage) settings.storage = toolStorage.createToolStorage(ctx, toolId);
|
|
31
|
+
// One stored game, one serialization key, no matter how many runtime
|
|
32
|
+
// instances are created for it.
|
|
33
|
+
if (!settings.lockKey) settings.lockKey = path.join(toolsRegistry.resolveToolsRoot(ctx), toolId);
|
|
34
|
+
return RUNTIME_FACTORIES[toolId](settings);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
hasRuntime: hasRuntime,
|
|
39
|
+
createRuntime: createRuntime,
|
|
40
|
+
ids: Object.keys(RUNTIME_FACTORIES),
|
|
41
|
+
};
|