def-game 5.1.0-timeout.0 → 6.1.0
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/README.md +11 -114
- package/bin/def-game.cjs +8 -124
- package/bin/init-worker.cjs +48 -0
- package/dist/worker/cloudflare/index.d.ts +5 -0
- package/dist/worker/cloudflare/index.d.ts.map +1 -0
- package/dist/worker/cloudflare/index.js +2 -0
- package/dist/worker/cloudflare/protocol.d.ts +11 -0
- package/dist/worker/cloudflare/protocol.d.ts.map +1 -0
- package/dist/worker/cloudflare/protocol.js +24 -0
- package/dist/worker/cloudflare/room-client.d.ts +28 -0
- package/dist/worker/cloudflare/room-client.d.ts.map +1 -0
- package/dist/worker/cloudflare/room-client.js +21 -0
- package/dist/worker/cloudflare/session-runtime.d.ts +27 -0
- package/dist/worker/cloudflare/session-runtime.d.ts.map +1 -0
- package/dist/worker/cloudflare/session-runtime.js +279 -0
- package/dist/worker/cloudflare/types.d.ts +99 -0
- package/dist/worker/cloudflare/types.d.ts.map +1 -0
- package/dist/worker/cloudflare/types.js +1 -0
- package/dist/worker/game-definition.d.ts +44 -0
- package/dist/worker/game-definition.d.ts.map +1 -0
- package/dist/worker/game-definition.js +1 -0
- package/dist/worker/package.json +1 -0
- package/docs/design-philosophy.md +410 -0
- package/docs/runtime-api.md +490 -0
- package/package.json +33 -7
- package/templates/starter/AGENTS.md +15 -0
- package/templates/starter/README.md +18 -0
- package/templates/starter/gitignore +5 -0
- package/templates/starter/package.json +15 -0
- package/templates/starter/public/app.js +93 -0
- package/templates/starter/public/index.html +37 -0
- package/templates/starter/scripts/dev-auth.mjs +40 -0
- package/templates/starter/scripts/dev.mjs +16 -0
- package/templates/starter/src/env.ts +10 -0
- package/templates/starter/src/external/decks.ts +15 -0
- package/templates/starter/src/game/definition.ts +79 -0
- package/templates/starter/src/game/types.ts +44 -0
- package/templates/starter/src/game-adapter.ts +29 -0
- package/templates/starter/src/index.ts +88 -0
- package/templates/starter/src/room.ts +9 -0
- package/templates/starter/tsconfig.json +8 -0
- package/templates/starter/wrangler.jsonc +14 -0
- package/templates/worker/env.ts +0 -9
- package/templates/worker/index.ts +0 -84
- package/templates/worker/runtime/game-adapter.ts +0 -15
- package/templates/worker/runtime/parse.ts +0 -14
- package/templates/worker/session.ts +0 -100
- package/templates/worker/timeout/runtime/game-adapter.ts +0 -18
- package/templates/worker/timeout/session.ts +0 -152
- /package/templates/{worker → starter/src}/auth.ts +0 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const $ = id => document.getElementById(id);
|
|
2
|
+
let roomId = new URL(location.href).searchParams.get('room');
|
|
3
|
+
let socket, view, stopped = false, refreshing;
|
|
4
|
+
const { issuer } = await (await fetch('/auth-config')).json();
|
|
5
|
+
const status = text => { $('status').textContent = text; };
|
|
6
|
+
|
|
7
|
+
function login() {
|
|
8
|
+
stopped = true;
|
|
9
|
+
const url = new URL('/login', issuer); url.searchParams.set('return_to', location.href); location.assign(url);
|
|
10
|
+
}
|
|
11
|
+
async function refresh() {
|
|
12
|
+
if (!refreshing) refreshing = fetch(new URL('/refresh', issuer), { credentials: 'include', cache: 'no-store' })
|
|
13
|
+
.then(response => { if (response.status === 401) { login(); return false; } if (!response.ok) throw Error('認証を更新できません'); return true; })
|
|
14
|
+
.finally(() => { refreshing = undefined; });
|
|
15
|
+
return refreshing;
|
|
16
|
+
}
|
|
17
|
+
/** 認証段階の401だけ再試行する。通信失敗やゲームCommandを自動再送しない。 */
|
|
18
|
+
async function api(path, body) {
|
|
19
|
+
const options = { credentials: 'include', cache: 'no-store', ...(body === undefined ? {} : {
|
|
20
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
21
|
+
}) };
|
|
22
|
+
let response = await fetch(path, options);
|
|
23
|
+
if (response.status === 401) {
|
|
24
|
+
if (!(await refresh())) throw Error('ログインへ移動します');
|
|
25
|
+
response = await fetch(path, options);
|
|
26
|
+
if (response.status === 401) { login(); throw Error('ログインが必要です'); }
|
|
27
|
+
}
|
|
28
|
+
const result = await response.json();
|
|
29
|
+
if (!response.ok || result.ok === false) throw Error(result.error?.detail ?? result.error?.code ?? '操作できませんでした');
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
function showRoomId() {
|
|
33
|
+
$('join').textContent = roomId ? 'この部屋に参加' : '部屋を作る';
|
|
34
|
+
$('invite-label').hidden = !roomId;
|
|
35
|
+
if (roomId) { history.replaceState(null, '', `?room=${encodeURIComponent(roomId)}`); $('invite').value = location.href; }
|
|
36
|
+
}
|
|
37
|
+
async function connect() {
|
|
38
|
+
if (stopped) return;
|
|
39
|
+
await api('/api/me'); // 再接続時に認証を確認する。接続中の定期再認証は行わない。
|
|
40
|
+
if (stopped) return;
|
|
41
|
+
const url = new URL(`/ws/rooms/${roomId}`, location.href); url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
42
|
+
const current = new WebSocket(url); socket = current;
|
|
43
|
+
current.onopen = () => { status('接続しました'); $('join-form').hidden = true; };
|
|
44
|
+
current.onmessage = event => {
|
|
45
|
+
const message = JSON.parse(event.data);
|
|
46
|
+
if (message.type === 'ViewStateEvent') { view = message.viewState; render(); }
|
|
47
|
+
if (message.ok === false || message.type === 'ProtocolErrorEvent') status(message.error?.detail ?? message.error?.code ?? '操作できませんでした');
|
|
48
|
+
};
|
|
49
|
+
current.onclose = event => {
|
|
50
|
+
if (socket !== current || stopped) return;
|
|
51
|
+
if (event.code === 1008) { status('この部屋への接続が許可されていません'); return; }
|
|
52
|
+
status('再接続しています…'); setTimeout(() => connect().catch(error => status(error.message)), 1500);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function send(command) {
|
|
56
|
+
if (socket?.readyState !== WebSocket.OPEN) { status('接続をお待ちください'); return; }
|
|
57
|
+
socket.send(JSON.stringify({ type: 'GameCommandRequest', requestId: crypto.randomUUID(), command }));
|
|
58
|
+
}
|
|
59
|
+
function render() {
|
|
60
|
+
$('room').hidden = false;
|
|
61
|
+
$('phase').textContent = { lobby: 'ロビー', playing: '対戦中', finished: '結果' }[view.phase];
|
|
62
|
+
$('players').replaceChildren(...view.players.map(player => {
|
|
63
|
+
const li = document.createElement('li'); li.textContent = `${player.name}${player.isYou ? '(あなた)' : ''} — ${player.score}点 / ${player.ready ? '準備済み' : 'デッキ未選択'}`; return li;
|
|
64
|
+
}));
|
|
65
|
+
$('deck-form').hidden = !view.availableActions.includes('select-deck');
|
|
66
|
+
$('start').hidden = !view.availableActions.includes('start');
|
|
67
|
+
$('turn').textContent = view.activePlayer ? `${view.activePlayer}さんの手番` : '';
|
|
68
|
+
$('cards').replaceChildren(...(view.phase === 'playing' ? view.yourCards : []).map(card => {
|
|
69
|
+
const button = document.createElement('button'); button.textContent = `${card.id} / ${card.power}点`;
|
|
70
|
+
button.disabled = !view.availableActions.includes('play');
|
|
71
|
+
button.onclick = () => send({ type: 'play', cardId: card.id, decisionId: view.decision.id }); return button;
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
$('join-form').onsubmit = async event => {
|
|
75
|
+
event.preventDefault(); $('join').disabled = true;
|
|
76
|
+
try {
|
|
77
|
+
if (!roomId) { roomId = (await api('/api/rooms', {})).roomId; showRoomId(); }
|
|
78
|
+
await api(`/api/rooms/${roomId}/join`, { name: $('name').value });
|
|
79
|
+
await connect();
|
|
80
|
+
} catch (error) { status(error.message); } finally { $('join').disabled = false; }
|
|
81
|
+
};
|
|
82
|
+
$('deck-form').onsubmit = async event => {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
try { await api(`/api/rooms/${roomId}/deck`, { deckId: $('deck').value }); } catch (error) { status(error.message); }
|
|
85
|
+
};
|
|
86
|
+
$('start').onclick = () => send({ type: 'start' });
|
|
87
|
+
showRoomId();
|
|
88
|
+
try {
|
|
89
|
+
await api('/api/me');
|
|
90
|
+
const { decks } = await api('/api/decks');
|
|
91
|
+
for (const deck of decks) { const option = document.createElement('option'); option.value = deck.id; option.textContent = deck.name; $('deck').append(option); }
|
|
92
|
+
status('表示名を入力してください');
|
|
93
|
+
} catch (error) { status(error.message); }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="ja">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
5
|
+
<title>DefGame Starter</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root { font-family: system-ui, sans-serif; color: #182c32; background: #f1f5f3; }
|
|
8
|
+
body { max-width: 700px; margin: 48px auto; padding: 0 24px; }
|
|
9
|
+
h1 { margin-bottom: 8px; } section { background: white; padding: 24px; margin: 20px 0; border-radius: 12px; }
|
|
10
|
+
input, button, select { font: inherit; padding: 10px; margin: 4px; border: 1px solid #a7b9b4; border-radius: 6px; }
|
|
11
|
+
button { cursor: pointer; background: #174d46; color: white; } button:disabled { opacity: .4; cursor: default; }
|
|
12
|
+
#status { min-height: 1.5em; } #invite { width: 95%; } li { margin: 12px 0; }
|
|
13
|
+
.muted { color: #607570; } [hidden] { display: none !important; }
|
|
14
|
+
</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<h1>DefGame Starter</h1>
|
|
18
|
+
<p class="muted">2人がデッキを選び、1枚ずつカードを出します。得点が高い方の勝ち。手番は30秒です。</p>
|
|
19
|
+
<p id="status" role="status">接続を準備しています…</p>
|
|
20
|
+
<section>
|
|
21
|
+
<form id="join-form">
|
|
22
|
+
<label>表示名 <input id="name" maxlength="24" required autocomplete="nickname"></label>
|
|
23
|
+
<button id="join">部屋を作る</button>
|
|
24
|
+
</form>
|
|
25
|
+
<label id="invite-label" hidden>このURLを相手に共有してください<input id="invite" readonly></label>
|
|
26
|
+
</section>
|
|
27
|
+
<section id="room" hidden>
|
|
28
|
+
<h2 id="phase">ロビー</h2>
|
|
29
|
+
<ul id="players"></ul>
|
|
30
|
+
<form id="deck-form"><label>デッキ <select id="deck"></select></label><button id="select-deck">選ぶ</button></form>
|
|
31
|
+
<button id="start" hidden>ゲーム開始</button>
|
|
32
|
+
<p id="turn"></p>
|
|
33
|
+
<div id="cards"></div>
|
|
34
|
+
</section>
|
|
35
|
+
<script type="module" src="/app.js"></script>
|
|
36
|
+
</body>
|
|
37
|
+
</html>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { generateKeyPair, exportJWK, SignJWT } from 'jose';
|
|
4
|
+
|
|
5
|
+
/** ローカル開発専用の認証サーバー。Workerからimportせず、公開デプロイに含めない。 */
|
|
6
|
+
export async function startDevAuth({ port = 8790, appOrigin = 'http://127.0.0.1:8787' } = {}) {
|
|
7
|
+
const { privateKey, publicKey } = await generateKeyPair('ES256', { extractable: true });
|
|
8
|
+
const jwk = { ...await exportJWK(publicKey), kid: 'local-development', alg: 'ES256', use: 'sig' };
|
|
9
|
+
let issuer;
|
|
10
|
+
const server = createServer((request, response) => {
|
|
11
|
+
void handle(request, response).catch(() => { response.writeHead(500); response.end('Local authentication failed'); });
|
|
12
|
+
});
|
|
13
|
+
async function handle(request, response) {
|
|
14
|
+
const url = new URL(request.url, issuer);
|
|
15
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
16
|
+
if (request.headers.origin && request.headers.origin !== appOrigin) { response.writeHead(403); response.end(); return; }
|
|
17
|
+
response.setHeader('Access-Control-Allow-Origin', appOrigin);
|
|
18
|
+
response.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
19
|
+
if (url.pathname === '/.well-known/jwks.json') {
|
|
20
|
+
response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify({ keys: [jwk] })); return;
|
|
21
|
+
}
|
|
22
|
+
if (url.pathname !== '/login' && url.pathname !== '/refresh') { response.writeHead(404); response.end(); return; }
|
|
23
|
+
const cookieActor = request.headers.cookie?.match(/(?:^|;\s*)__dev_actor=([0-9a-f-]{36})(?:;|$)/)?.[1];
|
|
24
|
+
if (url.pathname === '/refresh' && !cookieActor) { response.writeHead(401); response.end(); return; }
|
|
25
|
+
const actorId = cookieActor ?? randomUUID();
|
|
26
|
+
const token = await new SignJWT({}).setProtectedHeader({ alg: 'ES256', kid: jwk.kid })
|
|
27
|
+
.setIssuer(issuer).setAudience('def-game-local').setSubject(actorId).setIssuedAt().setExpirationTime('15m').sign(privateKey);
|
|
28
|
+
response.setHeader('Set-Cookie', [
|
|
29
|
+
`__token=${token}; HttpOnly; SameSite=Lax; Path=/`,
|
|
30
|
+
`__dev_actor=${actorId}; HttpOnly; SameSite=Lax; Path=/`,
|
|
31
|
+
]);
|
|
32
|
+
if (url.pathname === '/refresh') { response.writeHead(204); response.end(); return; }
|
|
33
|
+
const target = new URL(url.searchParams.get('return_to') ?? '/', appOrigin);
|
|
34
|
+
if (target.origin !== appOrigin) { response.writeHead(400); response.end('Invalid return URL'); return; }
|
|
35
|
+
response.writeHead(302, { Location: target.toString() }); response.end();
|
|
36
|
+
}
|
|
37
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
38
|
+
issuer = `http://127.0.0.1:${server.address().port}`;
|
|
39
|
+
return { issuer, jwksUrl: `${issuer}/.well-known/jwks.json`, close: () => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) };
|
|
40
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { startDevAuth } from './dev-auth.mjs';
|
|
5
|
+
|
|
6
|
+
// CLIの--varはローカル実行だけに適用する。本番のWrangler設定を書き換えない。
|
|
7
|
+
const auth = await startDevAuth();
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const worker = spawn(process.execPath, [path.join(path.dirname(require.resolve('wrangler/package.json')), 'bin/wrangler.js'), 'dev', '--local',
|
|
10
|
+
'--ip', '127.0.0.1', '--port', '8787',
|
|
11
|
+
'--var', `AUTH_ISSUER:${auth.issuer}`, '--var', 'AUTH_AUDIENCE:def-game-local', '--var', `AUTH_JWKS_URL:${auth.jwksUrl}`,
|
|
12
|
+
], { stdio: 'inherit' });
|
|
13
|
+
console.log('Open http://127.0.0.1:8787 — use another browser profile for the second player.');
|
|
14
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { worker.kill(signal); });
|
|
15
|
+
worker.once('error', async error => { console.error(error.message); await auth.close(); process.exitCode = 1; });
|
|
16
|
+
worker.once('exit', async code => { await auth.close(); process.exitCode = code ?? 0; });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RoomDurableObject } from './room.js';
|
|
2
|
+
|
|
3
|
+
/** アプリ所有のbinding。外部デッキ保存などを追加するときはここへ接続する。 */
|
|
4
|
+
export interface Env {
|
|
5
|
+
ROOMS: DurableObjectNamespace<RoomDurableObject>;
|
|
6
|
+
ASSETS: Fetcher;
|
|
7
|
+
AUTH_ISSUER: string;
|
|
8
|
+
AUTH_AUDIENCE: string;
|
|
9
|
+
AUTH_JWKS_URL: string;
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Deck } from '../game/types.js';
|
|
2
|
+
|
|
3
|
+
/** ゲーム進行から独立したデッキ取得の例。Aquare等ではここを個人保存・図鑑APIへ接続する。 */
|
|
4
|
+
const decks: readonly Deck[] = [
|
|
5
|
+
{ id: 'swift', name: 'Swift', cards: [{ id: 'swift-1', power: 2 }, { id: 'swift-2', power: 4 }] },
|
|
6
|
+
{ id: 'steady', name: 'Steady', cards: [{ id: 'steady-1', power: 3 }, { id: 'steady-2', power: 5 }] },
|
|
7
|
+
];
|
|
8
|
+
export function listDecks() { return decks.map(({ id, name }) => ({ id, name })); }
|
|
9
|
+
|
|
10
|
+
/** 認証済みActorからアクセス可否を判断できる入口。例では両方を全員に提供する。 */
|
|
11
|
+
export async function loadDeck(actorId: string, deckId: string): Promise<Deck | null> {
|
|
12
|
+
if (!actorId) return null;
|
|
13
|
+
const deck = decks.find(d => d.id === deckId);
|
|
14
|
+
return deck ? structuredClone(deck) : null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { GameDefinition, TransitionResult } from 'def-game';
|
|
2
|
+
import type { ActorCommand, SystemCommand, State, View, Effect, GameError } from './types.js';
|
|
3
|
+
|
|
4
|
+
const TURN_MS = 30_000;
|
|
5
|
+
const reject = (error: GameError): TransitionResult<State, Effect, GameError> => ({ ok: false, error });
|
|
6
|
+
|
|
7
|
+
/** 外部I/Oを行わず、次の入力を待てる状態とEffectを返す。 */
|
|
8
|
+
export const game: GameDefinition<State, ActorCommand | SystemCommand, string, View, Effect, GameError> = {
|
|
9
|
+
createInitialState: () => ({ phase: 'lobby', players: [], turn: 0, revision: 0, decision: null }),
|
|
10
|
+
handleCommand(state, command, context) {
|
|
11
|
+
if (command.type === 'decision-timeout') {
|
|
12
|
+
if (context.origin !== 'system' || !Number.isFinite(command.now)) return reject('InvalidCommand');
|
|
13
|
+
if (state.phase !== 'playing' || !state.decision || command.decisionId !== state.decision.id
|
|
14
|
+
|| command.now < state.decision.deadline) return reject('StaleDecision');
|
|
15
|
+
return finishTurn(state, 0, command.now);
|
|
16
|
+
}
|
|
17
|
+
if (context.origin !== 'actor') return reject('InvalidCommand');
|
|
18
|
+
const index = state.players.findIndex(p => p.actorId === context.actorId);
|
|
19
|
+
if (command.type === 'join') {
|
|
20
|
+
if (index >= 0) return { ok: true, state, effects: [] }; // 再送・再参加で席を増やさない
|
|
21
|
+
if (state.phase !== 'lobby') return reject('AlreadyStarted');
|
|
22
|
+
if (state.players.length >= 2) return reject('RoomFull');
|
|
23
|
+
if (typeof command.name !== 'string' || !command.name.trim() || command.name.trim().length > 24) return reject('InvalidName');
|
|
24
|
+
return { ok: true, state: { ...state, revision: state.revision + 1,
|
|
25
|
+
players: [...state.players, { actorId: context.actorId, name: command.name.trim(), deck: null, score: 0 }] }, effects: [] };
|
|
26
|
+
}
|
|
27
|
+
if (index < 0) return reject('NotMember');
|
|
28
|
+
if (command.type === 'select-deck') {
|
|
29
|
+
if (state.phase !== 'lobby') return reject('AlreadyStarted');
|
|
30
|
+
if (!command.deck.cards.length || command.deck.cards.some(c => !c.id || !Number.isFinite(c.power))) return reject('InvalidCard');
|
|
31
|
+
return { ok: true, state: { ...state, revision: state.revision + 1,
|
|
32
|
+
players: state.players.map((p, i) => i === index ? { ...p, deck: command.deck } : p) }, effects: [] };
|
|
33
|
+
}
|
|
34
|
+
if (!Number.isFinite(command.now)) return reject('InvalidCommand');
|
|
35
|
+
if (command.type === 'start') {
|
|
36
|
+
if (state.phase !== 'lobby') return reject('AlreadyStarted');
|
|
37
|
+
if (state.players.length !== 2 || state.players.some(p => !p.deck)) return reject('NotReady');
|
|
38
|
+
const decision = { id: `turn-${state.revision + 1}`, deadline: command.now + TURN_MS };
|
|
39
|
+
return { ok: true, state: { ...state, phase: 'playing', revision: state.revision + 1, decision },
|
|
40
|
+
effects: [{ type: 'schedule', decisionId: decision.id, deadline: decision.deadline }] };
|
|
41
|
+
}
|
|
42
|
+
if (command.type === 'play') {
|
|
43
|
+
if (state.phase !== 'playing' || !state.decision || state.decision.id !== command.decisionId
|
|
44
|
+
|| command.now >= state.decision.deadline) return reject('StaleDecision');
|
|
45
|
+
if (index !== state.turn) return reject('NotYourTurn');
|
|
46
|
+
const card = state.players[index].deck?.cards.find(c => c.id === command.cardId);
|
|
47
|
+
if (!card) return reject('InvalidCard');
|
|
48
|
+
return finishTurn(state, card.power, command.now);
|
|
49
|
+
}
|
|
50
|
+
return reject('InvalidCommand');
|
|
51
|
+
},
|
|
52
|
+
project(state, actorId) {
|
|
53
|
+
const index = state.players.findIndex(p => p.actorId === actorId);
|
|
54
|
+
const member = index >= 0;
|
|
55
|
+
return {
|
|
56
|
+
phase: state.phase,
|
|
57
|
+
players: state.players.map(p => ({ name: p.name, score: p.score, ready: p.deck !== null, isYou: p.actorId === actorId })),
|
|
58
|
+
yourCards: member ? state.players[index].deck?.cards ?? [] : [],
|
|
59
|
+
activePlayer: state.phase === 'playing' ? state.players[state.turn].name : null,
|
|
60
|
+
decision: state.decision,
|
|
61
|
+
availableActions: !member ? [] : state.phase === 'lobby'
|
|
62
|
+
? ['select-deck', ...(state.players.length === 2 && state.players.every(p => p.deck) ? ['start' as const] : [])]
|
|
63
|
+
: state.phase === 'playing' && index === state.turn ? ['play'] : [],
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** 手番終了と次の期限を同じ遷移で決める。期限切れは得点0として扱う。 */
|
|
69
|
+
function finishTurn(state: State, score: number, now: number): TransitionResult<State, Effect, GameError> {
|
|
70
|
+
const players = state.players.map((p, i) => i === state.turn ? { ...p, score } : p);
|
|
71
|
+
const revision = state.revision + 1;
|
|
72
|
+
if (state.turn + 1 === players.length) {
|
|
73
|
+
return { ok: true, state: { ...state, players, revision, phase: 'finished', decision: null },
|
|
74
|
+
effects: [{ type: 'cancel', decisionId: state.decision!.id }, { type: 'finished' }] };
|
|
75
|
+
}
|
|
76
|
+
const decision = { id: `turn-${revision}`, deadline: now + TURN_MS };
|
|
77
|
+
return { ok: true, state: { ...state, players, revision, turn: state.turn + 1, decision },
|
|
78
|
+
effects: [{ type: 'schedule', decisionId: decision.id, deadline: decision.deadline }] };
|
|
79
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { GameTypes } from 'def-game/cloudflare';
|
|
2
|
+
|
|
3
|
+
/** デッキの内容はアプリが取得し、ゲームがスナップショットとして保存する。 */
|
|
4
|
+
export interface Card { readonly id: string; readonly power: number }
|
|
5
|
+
export interface Deck { readonly id: string; readonly name: string; readonly cards: readonly Card[] }
|
|
6
|
+
export interface Player {
|
|
7
|
+
readonly actorId: string;
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly deck: Deck | null;
|
|
10
|
+
readonly score: number;
|
|
11
|
+
}
|
|
12
|
+
/** 所有者のいないロビーを初期状態とする、2人で1回ずつカードを出す最小ゲーム。 */
|
|
13
|
+
export interface State {
|
|
14
|
+
readonly phase: 'lobby' | 'playing' | 'finished';
|
|
15
|
+
readonly players: readonly Player[];
|
|
16
|
+
readonly turn: number;
|
|
17
|
+
readonly revision: number;
|
|
18
|
+
readonly decision: { readonly id: string; readonly deadline: number } | null;
|
|
19
|
+
}
|
|
20
|
+
export type ActorCommand =
|
|
21
|
+
| { readonly type: 'join'; readonly name: string }
|
|
22
|
+
| { readonly type: 'select-deck'; readonly deck: Deck }
|
|
23
|
+
| { readonly type: 'start'; readonly now: number }
|
|
24
|
+
| { readonly type: 'play'; readonly cardId: string; readonly decisionId: string; readonly now: number };
|
|
25
|
+
export type SystemCommand = { readonly type: 'decision-timeout'; readonly decisionId: string; readonly now: number };
|
|
26
|
+
export type Effect =
|
|
27
|
+
| { readonly type: 'schedule'; readonly decisionId: string; readonly deadline: number }
|
|
28
|
+
| { readonly type: 'cancel'; readonly decisionId: string }
|
|
29
|
+
| { readonly type: 'finished' };
|
|
30
|
+
export type GameError = 'AlreadyStarted' | 'RoomFull' | 'InvalidName' | 'NotMember' | 'NotReady'
|
|
31
|
+
| 'NotYourTurn' | 'InvalidCard' | 'StaleDecision' | 'InvalidCommand';
|
|
32
|
+
/** 他人のカードは公開しない。クライアントはavailableActionsに従って操作を表示する。 */
|
|
33
|
+
export interface View {
|
|
34
|
+
readonly phase: State['phase'];
|
|
35
|
+
readonly players: readonly { readonly name: string; readonly score: number; readonly ready: boolean; readonly isYou: boolean }[];
|
|
36
|
+
readonly yourCards: readonly Card[];
|
|
37
|
+
readonly activePlayer: string | null;
|
|
38
|
+
readonly decision: State['decision'];
|
|
39
|
+
readonly availableActions: readonly ('select-deck' | 'start' | 'play')[];
|
|
40
|
+
}
|
|
41
|
+
export interface Types extends GameTypes {
|
|
42
|
+
state: State; actorCommand: ActorCommand; systemCommand: SystemCommand;
|
|
43
|
+
view: View; effect: Effect; error: GameError;
|
|
44
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { GameAdapter } from 'def-game/cloudflare';
|
|
2
|
+
import type { Env } from './env.js';
|
|
3
|
+
import { game } from './game/definition.js';
|
|
4
|
+
import type { Types } from './game/types.js';
|
|
5
|
+
|
|
6
|
+
/** WSで許す入力を制限し、時刻はクライアントの自己申告を使わず実行側で付ける。 */
|
|
7
|
+
export const adapter: GameAdapter<Env, Types> = {
|
|
8
|
+
game,
|
|
9
|
+
webSocket: { parseCommand(input) {
|
|
10
|
+
if (typeof input !== 'object' || input === null || !('type' in input)) return null;
|
|
11
|
+
if (input.type === 'start') return { type: 'start', now: Date.now() };
|
|
12
|
+
if (input.type === 'play' && 'cardId' in input && typeof input.cardId === 'string'
|
|
13
|
+
&& 'decisionId' in input && typeof input.decisionId === 'string') {
|
|
14
|
+
return { type: 'play', cardId: input.cardId, decisionId: input.decisionId, now: Date.now() };
|
|
15
|
+
}
|
|
16
|
+
// select-deckはHTTPで外部情報を取得してからdispatchする。WSからの自己申告は拒否。
|
|
17
|
+
return null;
|
|
18
|
+
} },
|
|
19
|
+
canConnect: (state, actorId) => state.players.some(p => p.actorId === actorId),
|
|
20
|
+
timeout: {
|
|
21
|
+
effect: effect => effect.type === 'schedule' || effect.type === 'cancel' ? effect : null,
|
|
22
|
+
command: decisionId => ({ type: 'decision-timeout', decisionId, now: Date.now() }),
|
|
23
|
+
},
|
|
24
|
+
async executeEffect(effect, { roomId }) {
|
|
25
|
+
if (effect.type === 'finished') console.info('Game finished', { roomId });
|
|
26
|
+
// 外部処理の結果をゲームへ戻す場合は { command: SystemCommand } を返す。
|
|
27
|
+
// 保存済み状態を直接変更しない。外部配送はbest-effort。
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { getCookie } from 'hono/cookie';
|
|
3
|
+
import { getRoom } from 'def-game/cloudflare';
|
|
4
|
+
import { authenticate } from './auth.js';
|
|
5
|
+
import { listDecks, loadDeck } from './external/decks.js';
|
|
6
|
+
import type { Env } from './env.js';
|
|
7
|
+
export { RoomDurableObject } from './room.js';
|
|
8
|
+
|
|
9
|
+
const app = new Hono<{ Bindings: Env; Variables: { actorId: string } }>();
|
|
10
|
+
|
|
11
|
+
/** 認証とリクエスト保護を独自APIにも共通適用する。static shellに秘密情報は置かない。 */
|
|
12
|
+
app.use('*', async (c, next) => {
|
|
13
|
+
if (!c.req.path.startsWith('/api/') && !c.req.path.startsWith('/ws/')) return next();
|
|
14
|
+
c.header('Cache-Control', 'no-store');
|
|
15
|
+
const origin = c.req.header('origin');
|
|
16
|
+
if (origin && origin !== new URL(c.req.url).origin && (c.req.method !== 'GET' || c.req.path.startsWith('/ws/'))) {
|
|
17
|
+
return c.json({ ok: false, error: { code: 'InvalidOrigin' } }, 403);
|
|
18
|
+
}
|
|
19
|
+
const actorId = await authenticate(getCookie(c, '__token'), c.env);
|
|
20
|
+
if (!actorId) return c.json({ ok: false, error: { code: 'AuthenticationRequired' } }, 401);
|
|
21
|
+
c.set('actorId', actorId);
|
|
22
|
+
await next();
|
|
23
|
+
});
|
|
24
|
+
app.get('/auth-config', c => c.json({ issuer: c.env.AUTH_ISSUER }, 200, { 'Cache-Control': 'no-store' }));
|
|
25
|
+
app.get('/api/me', c => c.json({ ok: true, actorId: c.get('actorId') }));
|
|
26
|
+
app.get('/api/decks', c => c.json({ decks: listDecks() }));
|
|
27
|
+
|
|
28
|
+
app.post('/api/rooms', async c => {
|
|
29
|
+
// 公開room IDはアプリ所有。DO内部のIDと同じ形式にする必要はない。
|
|
30
|
+
const roomId = crypto.randomUUID();
|
|
31
|
+
const result = await getRoom(c.env.ROOMS, c.env.ROOMS.idFromName(roomId)).create();
|
|
32
|
+
return result.ok ? c.json({ ok: true, roomId }, 201) : c.json(result, 500);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
app.use('/api/rooms/:roomId/*', async (c, next) => {
|
|
36
|
+
if (!validRoomId(c.req.param('roomId'))) return c.json({ ok: false, error: { code: 'InvalidRoomId' } }, 400);
|
|
37
|
+
await next();
|
|
38
|
+
});
|
|
39
|
+
app.post('/api/rooms/:roomId/join', async c => {
|
|
40
|
+
const input = await readInput(c.req.raw);
|
|
41
|
+
if (!input || typeof input.name !== 'string') return c.json({ ok: false, error: { code: 'InvalidRequest' } }, 400);
|
|
42
|
+
const room = getRoom(c.env.ROOMS, c.env.ROOMS.idFromName(c.req.param('roomId')));
|
|
43
|
+
const result = await room.dispatchActor({ actorId: c.get('actorId') }, { type: 'join', name: input.name });
|
|
44
|
+
return c.json(result, result.ok ? 200 : 409);
|
|
45
|
+
});
|
|
46
|
+
app.post('/api/rooms/:roomId/deck', async c => {
|
|
47
|
+
const input = await readInput(c.req.raw);
|
|
48
|
+
if (!input || typeof input.deckId !== 'string') return c.json({ ok: false, error: { code: 'InvalidRequest' } }, 400);
|
|
49
|
+
// ここで外部ドメインの取得・権限確認を済ませる。本文のdeckやpowerは使用しない。
|
|
50
|
+
const deck = await loadDeck(c.get('actorId'), input.deckId);
|
|
51
|
+
if (!deck) return c.json({ ok: false, error: { code: 'DeckNotFound' } }, 404);
|
|
52
|
+
const room = getRoom(c.env.ROOMS, c.env.ROOMS.idFromName(c.req.param('roomId')));
|
|
53
|
+
const result = await room.dispatchActor({ actorId: c.get('actorId') }, { type: 'select-deck', deck });
|
|
54
|
+
return c.json(result, result.ok ? 200 : 409);
|
|
55
|
+
});
|
|
56
|
+
app.get('/ws/rooms/:roomId', c => {
|
|
57
|
+
if (!validRoomId(c.req.param('roomId'))) return c.json({ ok: false, error: { code: 'InvalidRoomId' } }, 400);
|
|
58
|
+
if (c.req.header('upgrade')?.toLowerCase() !== 'websocket') return c.json({ ok: false, error: { code: 'InvalidRequest' } }, 426);
|
|
59
|
+
return getRoom(c.env.ROOMS, c.env.ROOMS.idFromName(c.req.param('roomId'))).connect({ actorId: c.get('actorId') });
|
|
60
|
+
});
|
|
61
|
+
app.all('/api/*', c => c.json({ ok: false, error: { code: 'NotFound' } }, 404));
|
|
62
|
+
app.all('/ws/*', c => c.json({ ok: false, error: { code: 'NotFound' } }, 404));
|
|
63
|
+
app.all('*', c => c.env.ASSETS.fetch(c.req.raw));
|
|
64
|
+
app.onError((_error, c) => {
|
|
65
|
+
console.error('Worker request failed');
|
|
66
|
+
return c.json({ ok: false, error: { code: 'InternalError' } }, 500);
|
|
67
|
+
});
|
|
68
|
+
export default app;
|
|
69
|
+
|
|
70
|
+
function validRoomId(id: string) { return /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(id); }
|
|
71
|
+
/** 上限付きでJSONを読む。認可やゲームルールはここへ入れない。 */
|
|
72
|
+
async function readInput(request: Request): Promise<Record<string, unknown> | null> {
|
|
73
|
+
if (!request.headers.get('content-type')?.startsWith('application/json') || !request.body) return null;
|
|
74
|
+
const reader = request.body.getReader();
|
|
75
|
+
const chunks: Uint8Array[] = []; let length = 0;
|
|
76
|
+
while (true) {
|
|
77
|
+
const { done, value } = await reader.read(); if (done) break;
|
|
78
|
+
length += value.byteLength;
|
|
79
|
+
if (length > 64 * 1024) { await reader.cancel(); return null; }
|
|
80
|
+
chunks.push(value);
|
|
81
|
+
}
|
|
82
|
+
const bytes = new Uint8Array(length); let offset = 0;
|
|
83
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
|
84
|
+
try {
|
|
85
|
+
const input: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
|
86
|
+
return typeof input === 'object' && input !== null && !Array.isArray(input) ? input as Record<string, unknown> : null;
|
|
87
|
+
} catch { return null; }
|
|
88
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SessionRuntime } from 'def-game/cloudflare';
|
|
2
|
+
import { adapter } from './game-adapter.js';
|
|
3
|
+
import type { Env } from './env.js';
|
|
4
|
+
import type { Types } from './game/types.js';
|
|
5
|
+
|
|
6
|
+
/** 共通DO実装にこのゲームのadapterを接続する。状態保存・WS管理を複製しない。 */
|
|
7
|
+
export class RoomDurableObject extends SessionRuntime<Env, Types> {
|
|
8
|
+
protected readonly adapter = adapter;
|
|
9
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
3
|
+
"name": "def-game-starter",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"compatibility_date": "2026-09-12",
|
|
6
|
+
"assets": { "directory": "public", "binding": "ASSETS", "run_worker_first": true },
|
|
7
|
+
"durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "RoomDurableObject" }] },
|
|
8
|
+
"exports": { "RoomDurableObject": { "type": "durable-object", "storage": "sqlite" } },
|
|
9
|
+
"vars": {
|
|
10
|
+
"AUTH_ISSUER": "https://auth.waki.work",
|
|
11
|
+
"AUTH_AUDIENCE": "waki.work",
|
|
12
|
+
"AUTH_JWKS_URL": "https://auth.waki.work/.well-known/jwks.json"
|
|
13
|
+
}
|
|
14
|
+
}
|
package/templates/worker/env.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { Hono, type Context } from "hono";
|
|
2
|
-
import { getCookie } from "hono/cookie";
|
|
3
|
-
import type { ActorId, CreateSessionResponse, JoinSessionResponse, ProtocolError, PublicError } from __ADAPTER_IMPORT__;
|
|
4
|
-
import type { Env } from "./env.js";
|
|
5
|
-
import { authenticate } from "./auth.js";
|
|
6
|
-
export { SessionDurableObject } from "./session.js";
|
|
7
|
-
|
|
8
|
-
type AppEnv = { Bindings: Env; Variables: { actorId: ActorId } };
|
|
9
|
-
const app = new Hono<AppEnv>();
|
|
10
|
-
|
|
11
|
-
// Static Assets を含む全ルートで認証し、以後は検証済み ActorId を context から取得する。
|
|
12
|
-
app.use("*", async (c, next) => {
|
|
13
|
-
const actorId = await authenticate(getCookie(c, "__token"), c.env);
|
|
14
|
-
if (!actorId) return errorResponse(c, "AuthenticationRequired", 401);
|
|
15
|
-
c.set("actorId", actorId);
|
|
16
|
-
await next();
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
app.use("*", async (c, next) => {
|
|
20
|
-
const origin = c.req.header("origin");
|
|
21
|
-
const isWebSocket = c.req.path.startsWith("/ws/");
|
|
22
|
-
if (origin && origin !== new URL(c.req.url).origin && (c.req.method !== "GET" || isWebSocket)) {
|
|
23
|
-
return errorResponse(c, "InvalidRequest", 403);
|
|
24
|
-
}
|
|
25
|
-
await next();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
app.post("/api/sessions", (c) => joinSession(c, c.env.SESSIONS.newUniqueId(), "create"));
|
|
29
|
-
|
|
30
|
-
app.post("/api/sessions/:sessionId/join", (c) => {
|
|
31
|
-
const sessionId = c.req.param("sessionId");
|
|
32
|
-
if (!/^[0-9a-f]{64}$/.test(sessionId)) return errorResponse(c, "InvalidRequest", 404);
|
|
33
|
-
return joinSession(c, c.env.SESSIONS.idFromString(sessionId), "join");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
app.get("/ws/:sessionId", (c) => {
|
|
37
|
-
const sessionId = c.req.param("sessionId");
|
|
38
|
-
if (!/^[0-9a-f]{64}$/.test(sessionId)) return errorResponse(c, "InvalidRequest", 404);
|
|
39
|
-
if (c.req.header("upgrade")?.toLowerCase() !== "websocket") return errorResponse(c, "InvalidRequest", 426);
|
|
40
|
-
const id = c.env.SESSIONS.idFromString(sessionId);
|
|
41
|
-
// DO の Hibernation 接続をそのまま返す。token や外部のヘッダーは転送しない。
|
|
42
|
-
return c.env.SESSIONS.get(id).fetch(new Request("https://session/connect", {
|
|
43
|
-
headers: { upgrade: "websocket", "x-actor-id": c.get("actorId") },
|
|
44
|
-
}));
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
app.all("/api/*", (c) => errorResponse(c, "InvalidRequest", 404));
|
|
48
|
-
app.all("/ws/*", (c) => errorResponse(c, "InvalidRequest", 404));
|
|
49
|
-
app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
|
|
50
|
-
|
|
51
|
-
app.onError((_error, c) => {
|
|
52
|
-
console.error("Worker request failed");
|
|
53
|
-
return errorResponse(c, "InternalError", 500);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
export default app;
|
|
57
|
-
|
|
58
|
-
/** create と join に共通する本文検証と DO 呼び出し。本文のゲーム固有形式と参加可否は DO 内の adapter / domain が判断する。 */
|
|
59
|
-
async function joinSession(c: Context<AppEnv>, id: DurableObjectId, operation: "create" | "join"): Promise<Response> {
|
|
60
|
-
if (!c.req.header("content-type")?.startsWith("application/json")) return errorResponse(c, "InvalidRequest", 400);
|
|
61
|
-
const raw = await c.req.text();
|
|
62
|
-
if (raw.length > 64 * 1024) return errorResponse(c, "InvalidRequest", 400);
|
|
63
|
-
let body: unknown;
|
|
64
|
-
try { body = JSON.parse(raw); } catch { return errorResponse(c, "InvalidRequest", 400); }
|
|
65
|
-
|
|
66
|
-
const result = await c.env.SESSIONS.get(id).fetch(new Request(`https://session/${operation}`, {
|
|
67
|
-
method: "POST", headers: { "x-actor-id": c.get("actorId"), "content-type": "application/json" },
|
|
68
|
-
body: JSON.stringify(body),
|
|
69
|
-
}));
|
|
70
|
-
const data = await result.json<{ ok: true } | { ok: false; error: PublicError }>();
|
|
71
|
-
const type = operation === "create" ? "CreateSessionResponse" : "JoinSessionResponse";
|
|
72
|
-
const response: CreateSessionResponse | JoinSessionResponse = data.ok
|
|
73
|
-
? { type, ok: true, sessionId: id.toString() }
|
|
74
|
-
: { type, ok: false, error: data.error };
|
|
75
|
-
return Response.json(response, { status: result.status });
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** middleware で拒否する場合も、既存の公開レスポンス形式を維持する。 */
|
|
79
|
-
function errorResponse(c: Context<AppEnv>, code: ProtocolError["code"], status: 400 | 401 | 403 | 404 | 426 | 500): Response {
|
|
80
|
-
const type = c.req.method === "POST" && c.req.path === "/api/sessions"
|
|
81
|
-
? "CreateSessionResponse"
|
|
82
|
-
: /^\/api\/sessions\/[0-9a-f]{64}\/join$/.test(c.req.path) ? "JoinSessionResponse" : undefined;
|
|
83
|
-
return c.json({ ...(type ? { type } : {}), ok: false, error: { code } }, status);
|
|
84
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import type { GameDefinition } from "def-game";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 生成対象の runtime とゲーム固有コードの接続契約。GameDefinition 自体は変更しない。
|
|
5
|
-
* decoder は未検証の本文からゲーム command を作り、形式不正なら null を返す。
|
|
6
|
-
* 参加・復帰の意味や認可は game.handleCommand が判断する。
|
|
7
|
-
* 現段階は Effect なし。timeout / Effect の生成オプションは実装時に別途追加する。
|
|
8
|
-
*/
|
|
9
|
-
export interface GameAdapter<State, Command, View, Error> {
|
|
10
|
-
readonly game: GameDefinition<State, Command, string, View, never, Error>;
|
|
11
|
-
readonly parseCreate: (input: unknown) => Command | null;
|
|
12
|
-
readonly parseJoin: (input: unknown) => Command | null;
|
|
13
|
-
readonly parseCommand: (input: unknown) => Command | null;
|
|
14
|
-
readonly canConnect: (state: State, actorId: string) => boolean;
|
|
15
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
/** 通信 envelope だけを検証する。command の名前・payload の意味は adapter が判断する。 */
|
|
6
|
-
export function parseCommandRequest(raw: string | ArrayBuffer): { requestId: string; command: unknown } | null {
|
|
7
|
-
if (typeof raw !== "string" || raw.length > 64 * 1024) return null;
|
|
8
|
-
try {
|
|
9
|
-
const value: unknown = JSON.parse(raw);
|
|
10
|
-
if (!isRecord(value) || value.type !== "GameCommandRequest" || typeof value.requestId !== "string"
|
|
11
|
-
|| value.requestId.length === 0 || value.requestId.length > 128 || !Object.hasOwn(value, "command")) return null;
|
|
12
|
-
return { requestId: value.requestId, command: value.command };
|
|
13
|
-
} catch { return null; }
|
|
14
|
-
}
|