@twinsearth/w2m-dsh-plugin 2.0.7 → 2.0.8
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +14 -5
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.0.8/RELEASE-2080.json +21 -0
- package/docs/plans/IM2A-2.2.2-PLAN.md +293 -0
- package/lib/client.js +1 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/chacha.mjs +93 -0
- package/src/agent/crypto.mjs +64 -29
- package/src/agent/transfer/ferry.mjs +195 -0
- package/src/agent/transfer/index.mjs +4 -0
- package/src/agent/transfer/manifest.mjs +92 -0
- package/src/agent/transfer/object-store.mjs +144 -0
- package/src/agent/transfer/publish.mjs +76 -0
- package/src/agent/transfer/reference.mjs +97 -0
- package/src/agent/transfer/want-block.mjs +89 -0
- package/src/game/xiangqi.mjs +217 -0
- package/src/plugin/fleet-binding.mjs +105 -0
- package/src/plugin/tools.mjs +26 -4
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ArtifactRef v2 + 本地引用登记(C2 的基础形状)与解析阶梯(C3 的本地分支)。
|
|
3
|
+
*
|
|
4
|
+
* 控制面只承载元数据:object_id / task / machine / kind / sink / 大小 / 块数 / 根哈希 / TTL。
|
|
5
|
+
* 字节永远在本机对象库或用户 sink 里。resolver 不回落 relay:本地没有就明确
|
|
6
|
+
* TRANSFER_UNREACHABLE(远端 P2P 拉取是下一阶段接线)。
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
12
|
+
const OBJECT = /^[0-9a-f]{32,64}$/;
|
|
13
|
+
|
|
14
|
+
export function createRef(manifest, { ttlMs = 24 * 3600 * 1000, now = Date.now } = {}) {
|
|
15
|
+
if (manifest === null || typeof manifest !== 'object') return { ok: false, code: 'XFER_MANIFEST_INVALID' };
|
|
16
|
+
if (!OBJECT.test(String(manifest.object_id ?? ''))) return { ok: false, code: 'XFER_OBJECT_ID_INVALID' };
|
|
17
|
+
if (!HEX64.test(String(manifest.root_sha256 ?? ''))) return { ok: false, code: 'XFER_ROOT_INVALID' };
|
|
18
|
+
if (!Number.isSafeInteger(manifest.size_bytes) || !Number.isSafeInteger(manifest.chunks)) return { ok: false, code: 'XFER_MANIFEST_INVALID' };
|
|
19
|
+
return {
|
|
20
|
+
ok: true,
|
|
21
|
+
ref: {
|
|
22
|
+
v: 2,
|
|
23
|
+
object_id: manifest.object_id,
|
|
24
|
+
task_id: manifest.task_id ?? null,
|
|
25
|
+
machine_id: manifest.machine_id ?? null,
|
|
26
|
+
kind: manifest.kind ?? 'stdout',
|
|
27
|
+
sink: { kind: manifest.sink?.kind ?? 'cache', rel_path: manifest.sink?.rel_path ?? manifest.object_id + '.bin' },
|
|
28
|
+
size_bytes: manifest.size_bytes,
|
|
29
|
+
chunk_bytes: manifest.chunk_bytes,
|
|
30
|
+
chunks: manifest.chunks,
|
|
31
|
+
root_sha256: manifest.root_sha256,
|
|
32
|
+
created_at: new Date(now()).toISOString(),
|
|
33
|
+
expires_at: new Date(now() + ttlMs).toISOString(),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function verifyRef(ref, { now = Date.now } = {}) {
|
|
39
|
+
const nowMs = typeof now === 'function' ? now() : now;
|
|
40
|
+
if (ref === null || typeof ref !== 'object' || ref.v !== 2) return { ok: false, code: 'DELIVERY_REF_INVALID' };
|
|
41
|
+
if (!OBJECT.test(String(ref.object_id ?? ''))) return { ok: false, code: 'DELIVERY_REF_INVALID' };
|
|
42
|
+
if (!HEX64.test(String(ref.root_sha256 ?? ''))) return { ok: false, code: 'DELIVERY_REF_INVALID' };
|
|
43
|
+
if (!Number.isSafeInteger(ref.size_bytes) || !Number.isSafeInteger(ref.chunks) || ref.chunks < 0) return { ok: false, code: 'DELIVERY_REF_INVALID' };
|
|
44
|
+
if (typeof ref.expires_at === 'string' && Date.parse(ref.expires_at) <= nowMs) return { ok: false, code: 'DELIVERY_STALE' };
|
|
45
|
+
return { ok: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createRefRegistry({ root }) {
|
|
49
|
+
if (typeof root !== 'string' || root === '') throw new TypeError('XFER_REF_ROOT_REQUIRED');
|
|
50
|
+
const dir = join(root, 'refs');
|
|
51
|
+
const fileOf = (objectId) => join(dir, objectId + '.json');
|
|
52
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
53
|
+
return {
|
|
54
|
+
publish(ref) {
|
|
55
|
+
const checked = verifyRef(ref);
|
|
56
|
+
if (checked.ok !== true) return checked;
|
|
57
|
+
const file = fileOf(ref.object_id);
|
|
58
|
+
writeFileSync(file, JSON.stringify(ref), { encoding: 'utf8', mode: 0o600 });
|
|
59
|
+
return { ok: true, ref, path: file };
|
|
60
|
+
},
|
|
61
|
+
get(objectId) {
|
|
62
|
+
try {
|
|
63
|
+
const ref = JSON.parse(readFileSync(fileOf(objectId), 'utf8').replace(/^\uFEFF/, ''));
|
|
64
|
+
const checked = verifyRef(ref);
|
|
65
|
+
return checked.ok === true ? { ok: true, ref } : checked;
|
|
66
|
+
} catch {
|
|
67
|
+
return { ok: false, code: 'DELIVERY_REF_NOT_FOUND' };
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
list() {
|
|
71
|
+
const refs = [];
|
|
72
|
+
let names = [];
|
|
73
|
+
try { names = readdirSync(dir); } catch { return { ok: true, refs }; }
|
|
74
|
+
for (const name of names) {
|
|
75
|
+
if (!name.endsWith('.json')) continue;
|
|
76
|
+
try { const ref = JSON.parse(readFileSync(join(dir, name), 'utf8')); if (verifyRef(ref).ok === true) refs.push(ref); } catch { /* skip corrupt */ }
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, refs };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 本地解析:对象已完整且根哈希匹配 → 返回路径;否则显式失败(不回落 relay)。
|
|
85
|
+
* 远端 P2P 拉取(C3 的 xfer 分支)在下一阶段接入,这里保留明确的失败语义。
|
|
86
|
+
*/
|
|
87
|
+
export function resolveLocal(ref, { store }) {
|
|
88
|
+
const checked = verifyRef(ref);
|
|
89
|
+
if (checked.ok !== true) return checked;
|
|
90
|
+
const local = store.has(ref.object_id);
|
|
91
|
+
if (local.ok !== true) return { ok: false, code: 'TRANSFER_UNREACHABLE', detail: { object_id: ref.object_id, reason: 'not in local object store; remote P2P fetch not wired yet' } };
|
|
92
|
+
if (local.complete !== true) return { ok: false, code: 'TRANSFER_INCOMPLETE', detail: { object_id: ref.object_id } };
|
|
93
|
+
const manifestPath = join(store.root, ref.object_id + '.ref.json');
|
|
94
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8').replace(/^\uFEFF/, ''));
|
|
95
|
+
if (manifest.root_sha256 !== ref.root_sha256) return { ok: false, code: 'DELIVERY_ROOT_MISMATCH' };
|
|
96
|
+
return { ok: true, path: join(store.root, ref.object_id + '.bin'), bytes: manifest.size_bytes, object_id: ref.object_id };
|
|
97
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2P want-block 协议(传输无关的最小实现)。
|
|
3
|
+
*
|
|
4
|
+
* 帧只在两台机器之间流动,绝不经过 relay:
|
|
5
|
+
* xfer.want { transferId, rootCid, indices }
|
|
6
|
+
* xfer.chunk { transferId, index, sha256, size, data(base64) }
|
|
7
|
+
* xfer.error { transferId, code, index? }
|
|
8
|
+
* 接收端把 chunk 交给 createReceiver().writeChunk;全部到齐后 finalize。
|
|
9
|
+
* 传输由调用方注入(P2PChannel / 测试用 duplex),本模块不发起 HTTP。
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { readSync, openSync, closeSync } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
export const WANT = 'xfer.want';
|
|
15
|
+
export const CHUNK = 'xfer.chunk';
|
|
16
|
+
export const ERROR = 'xfer.error';
|
|
17
|
+
export const MAX_WINDOW = 8;
|
|
18
|
+
export const MAX_CHUNK_B64 = 3 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
const isHex64 = (value) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
|
21
|
+
|
|
22
|
+
/** 发送端:按请求读取文件块并编码为帧。 */
|
|
23
|
+
export function createChunkSource({ chunked, filePath, transferId = null }) {
|
|
24
|
+
if (chunked === null || typeof chunked !== 'object' || !Array.isArray(chunked.chunks)) return { ok: false, code: 'XFER_SOURCE_INVALID' };
|
|
25
|
+
const tid = transferId ?? chunked.rootCid;
|
|
26
|
+
return {
|
|
27
|
+
ok: true,
|
|
28
|
+
handle(frame) {
|
|
29
|
+
if (frame?.type !== WANT) return { ok: false, code: 'XFER_FRAME_UNEXPECTED' };
|
|
30
|
+
if (frame.transferId !== tid) return { ok: false, code: 'XFER_TRANSFER_MISMATCH' };
|
|
31
|
+
if (frame.rootCid !== chunked.rootCid) return { ok: false, code: 'XFER_ROOT_MISMATCH' };
|
|
32
|
+
const indices = Array.isArray(frame.indices) ? frame.indices : [];
|
|
33
|
+
if (indices.length === 0 || indices.length > MAX_WINDOW) return { ok: false, code: 'XFER_WINDOW_INVALID' };
|
|
34
|
+
const out = [];
|
|
35
|
+
const fd = openSync(filePath, 'r');
|
|
36
|
+
try {
|
|
37
|
+
for (const index of indices) {
|
|
38
|
+
const chunk = chunked.chunks[index];
|
|
39
|
+
if (!Number.isInteger(index) || chunk === undefined) { out.push({ type: ERROR, transferId: tid, code: 'XFER_CHUNK_INDEX_INVALID', index }); continue; }
|
|
40
|
+
const buf = Buffer.alloc(chunk.size);
|
|
41
|
+
const read = readSync(fd, buf, 0, chunk.size, index * chunked.chunkBytes);
|
|
42
|
+
if (read !== chunk.size) { out.push({ type: ERROR, transferId: tid, code: 'XFER_READ_SHORT', index }); continue; }
|
|
43
|
+
out.push({ type: CHUNK, transferId: tid, index, sha256: chunk.sha256, size: chunk.size, data: buf.toString('base64') });
|
|
44
|
+
}
|
|
45
|
+
} finally { closeSync(fd); }
|
|
46
|
+
return { ok: true, frames: out };
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 接收端:把帧写入 createReceiver;缺失块由 receiver 的位图反推。 */
|
|
52
|
+
export function createWantClient({ transferId, rootCid, receiver }) {
|
|
53
|
+
if (typeof transferId !== 'string' || !isHex64(rootCid?.replace(/^sha256:/, ''))) return { ok: false, code: 'XFER_ROOT_INVALID' };
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
missing() {
|
|
57
|
+
const have = receiver.have();
|
|
58
|
+
const total = receiver.progress().total;
|
|
59
|
+
const out = [];
|
|
60
|
+
for (let index = 0; index < total; index += 1) if (!have.has(index)) out.push(index);
|
|
61
|
+
return out;
|
|
62
|
+
},
|
|
63
|
+
requestFrames(indices) {
|
|
64
|
+
return [{ type: WANT, transferId, rootCid, indices: indices.slice(0, MAX_WINDOW) }];
|
|
65
|
+
},
|
|
66
|
+
accept(frame) {
|
|
67
|
+
if (frame?.type === ERROR) return { ok: false, code: frame.code ?? 'XFER_PEER_ERROR', index: frame.index ?? null };
|
|
68
|
+
if (frame?.type !== CHUNK) return { ok: false, code: 'XFER_FRAME_UNEXPECTED' };
|
|
69
|
+
if (frame.transferId !== transferId) return { ok: false, code: 'XFER_TRANSFER_MISMATCH' };
|
|
70
|
+
if (String(frame.data ?? '').length > MAX_CHUNK_B64) return { ok: false, code: 'XFER_CHUNK_TOO_LARGE' };
|
|
71
|
+
const data = Buffer.from(String(frame.data), 'base64');
|
|
72
|
+
const written = receiver.writeChunk(frame.index, data, frame.sha256);
|
|
73
|
+
if (written.ok !== true) return written;
|
|
74
|
+
return { ok: true, index: frame.index, complete: receiver.progress().complete };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 两帧之间的纯函数检查:块哈希与大小必须与声明一致(不依赖 receiver)。 */
|
|
80
|
+
export function verifyChunkFrame(frame) {
|
|
81
|
+
if (frame?.type !== CHUNK) return { ok: false, code: 'XFER_FRAME_UNEXPECTED' };
|
|
82
|
+
if (!Number.isInteger(frame.index) || frame.index < 0) return { ok: false, code: 'XFER_CHUNK_INDEX_INVALID' };
|
|
83
|
+
if (!isHex64(frame.sha256)) return { ok: false, code: 'XFER_CHUNK_HASH_INVALID' };
|
|
84
|
+
const data = Buffer.from(String(frame.data ?? ''), 'base64');
|
|
85
|
+
if (data.length !== frame.size) return { ok: false, code: 'XFER_CHUNK_LENGTH_INVALID' };
|
|
86
|
+
const digest = createHash('sha256').update(data).digest('hex');
|
|
87
|
+
if (digest !== frame.sha256) return { ok: false, code: 'XFER_CHUNK_HASH_MISMATCH' };
|
|
88
|
+
return { ok: true, data };
|
|
89
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 中国象棋核心规则(联合开发演示用,零依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 棋盘 10 行 × 9 列:row 0 为黑方底线,row 9 为红方底线;红先。
|
|
5
|
+
* 棋子:K 帅/将, A 仕/士, E 相/象, H 马, R 车, C 炮, P 兵/卒。
|
|
6
|
+
* 已实现:全部走子规则、炮架、马腿、象眼、过河兵、九宫、飞将、将军/将死/困毙(困毙判负)。
|
|
7
|
+
*/
|
|
8
|
+
export const COLS = 9;
|
|
9
|
+
export const ROWS = 10;
|
|
10
|
+
const RED = 'r';
|
|
11
|
+
const BLACK = 'b';
|
|
12
|
+
|
|
13
|
+
const START = [
|
|
14
|
+
['r', 'h', 'e', 'a', 'k', 'a', 'e', 'h', 'r'],
|
|
15
|
+
[null, null, null, null, null, null, null, null, null],
|
|
16
|
+
[null, 'c', null, null, null, null, null, 'c', null],
|
|
17
|
+
['p', null, 'p', null, 'p', null, 'p', null, 'p'],
|
|
18
|
+
[null, null, null, null, null, null, null, null, null],
|
|
19
|
+
[null, null, null, null, null, null, null, null, null],
|
|
20
|
+
['P', null, 'P', null, 'P', null, 'P', null, 'P'],
|
|
21
|
+
[null, 'C', null, null, null, null, null, 'C', null],
|
|
22
|
+
[null, null, null, null, null, null, null, null, null],
|
|
23
|
+
['R', 'H', 'E', 'A', 'K', 'A', 'E', 'H', 'R'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const isRed = (piece) => piece !== null && piece === piece.toUpperCase();
|
|
27
|
+
const sideOf = (piece) => (piece === null ? null : isRed(piece) ? RED : BLACK);
|
|
28
|
+
|
|
29
|
+
export function createGame() {
|
|
30
|
+
return {
|
|
31
|
+
board: START.map((row) => row.slice()),
|
|
32
|
+
turn: RED,
|
|
33
|
+
ply: 0,
|
|
34
|
+
history: [],
|
|
35
|
+
result: null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cloneBoard = (board) => board.map((row) => row.slice());
|
|
40
|
+
const inBoard = (r, c) => r >= 0 && r < ROWS && c >= 0 && c < COLS;
|
|
41
|
+
const inPalace = (side, r, c) => {
|
|
42
|
+
if (c < 3 || c > 5) return false;
|
|
43
|
+
return side === RED ? r >= 7 && r <= 9 : r >= 0 && r <= 2;
|
|
44
|
+
};
|
|
45
|
+
const crossedRiver = (side, r) => (side === RED ? r <= 4 : r >= 5);
|
|
46
|
+
|
|
47
|
+
function pseudoMoves(board, side, r, c) {
|
|
48
|
+
const piece = board[r][c];
|
|
49
|
+
if (piece === null || sideOf(piece) !== side) return [];
|
|
50
|
+
const type = piece.toUpperCase();
|
|
51
|
+
const out = [];
|
|
52
|
+
const push = (rr, cc) => {
|
|
53
|
+
if (!inBoard(rr, cc)) return;
|
|
54
|
+
const target = board[rr][cc];
|
|
55
|
+
if (target === null || sideOf(target) !== side) out.push([rr, cc]);
|
|
56
|
+
};
|
|
57
|
+
if (type === 'K') {
|
|
58
|
+
for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
59
|
+
const rr = r + dr; const cc = c + dc;
|
|
60
|
+
if (inPalace(side, rr, cc)) push(rr, cc);
|
|
61
|
+
}
|
|
62
|
+
} else if (type === 'A') {
|
|
63
|
+
for (const [dr, dc] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {
|
|
64
|
+
const rr = r + dr; const cc = c + dc;
|
|
65
|
+
if (inPalace(side, rr, cc)) push(rr, cc);
|
|
66
|
+
}
|
|
67
|
+
} else if (type === 'E') {
|
|
68
|
+
for (const [dr, dc] of [[2, 2], [2, -2], [-2, 2], [-2, -2]]) {
|
|
69
|
+
const rr = r + dr; const cc = c + dc;
|
|
70
|
+
if (!inBoard(rr, cc) || crossedRiver(side, rr)) continue;
|
|
71
|
+
if (board[r + dr / 2][c + dc / 2] !== null) continue;
|
|
72
|
+
push(rr, cc);
|
|
73
|
+
}
|
|
74
|
+
} else if (type === 'H') {
|
|
75
|
+
for (const [dr, dc, lr, lc] of [
|
|
76
|
+
[2, 1, 1, 0], [2, -1, 1, 0], [-2, 1, -1, 0], [-2, -1, -1, 0],
|
|
77
|
+
[1, 2, 0, 1], [-1, 2, 0, 1], [1, -2, 0, -1], [-1, -2, 0, -1],
|
|
78
|
+
]) {
|
|
79
|
+
const rr = r + dr; const cc = c + dc;
|
|
80
|
+
if (!inBoard(rr, cc)) continue;
|
|
81
|
+
if (board[r + lr][c + lc] !== null) continue;
|
|
82
|
+
push(rr, cc);
|
|
83
|
+
}
|
|
84
|
+
} else if (type === 'R' || type === 'C') {
|
|
85
|
+
for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
86
|
+
let rr = r + dr; let cc = c + dc; let screen = false;
|
|
87
|
+
while (inBoard(rr, cc)) {
|
|
88
|
+
const target = board[rr][cc];
|
|
89
|
+
if (type === 'R') {
|
|
90
|
+
if (target === null) out.push([rr, cc]);
|
|
91
|
+
else { if (sideOf(target) !== side) out.push([rr, cc]); break; }
|
|
92
|
+
} else if (target === null) {
|
|
93
|
+
if (!screen) out.push([rr, cc]);
|
|
94
|
+
} else if (!screen) {
|
|
95
|
+
screen = true;
|
|
96
|
+
} else {
|
|
97
|
+
if (sideOf(target) !== side) out.push([rr, cc]);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
rr += dr; cc += dc;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} else if (type === 'P') {
|
|
104
|
+
const forward = side === RED ? -1 : 1;
|
|
105
|
+
push(r + forward, c);
|
|
106
|
+
if (crossedRiver(side, r)) { push(r, c - 1); push(r, c + 1); }
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function kingAt(board, side) {
|
|
112
|
+
for (let r = 0; r < ROWS; r += 1) for (let c = 0; c < COLS; c += 1) {
|
|
113
|
+
const p = board[r][c];
|
|
114
|
+
if (p !== null && p.toUpperCase() === 'K' && sideOf(p) === side) return [r, c];
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function kingsFace(board) {
|
|
120
|
+
const rk = kingAt(board, RED); const bk = kingAt(board, BLACK);
|
|
121
|
+
if (rk === null || bk === null || rk[1] !== bk[1]) return false;
|
|
122
|
+
const c = rk[1];
|
|
123
|
+
const from = Math.min(rk[0], bk[0]) + 1; const to = Math.max(rk[0], bk[0]);
|
|
124
|
+
for (let r = from; r < to; r += 1) if (board[r][c] !== null) return false;
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 某方是否被将军(含飞将)。 */
|
|
129
|
+
export function inCheck(board, side) {
|
|
130
|
+
if (kingsFace(board)) return true;
|
|
131
|
+
const king = kingAt(board, side);
|
|
132
|
+
if (king === null) return true;
|
|
133
|
+
const enemy = side === RED ? BLACK : RED;
|
|
134
|
+
for (let r = 0; r < ROWS; r += 1) for (let c = 0; c < COLS; c += 1) {
|
|
135
|
+
const p = board[r][c];
|
|
136
|
+
if (p === null || sideOf(p) !== enemy) continue;
|
|
137
|
+
if (pseudoMoves(board, enemy, r, c).some(([rr, cc]) => rr === king[0] && cc === king[1])) return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function legalMovesFrom(board, side, r, c) {
|
|
143
|
+
return pseudoMoves(board, side, r, c).filter(([rr, cc]) => {
|
|
144
|
+
const next = cloneBoard(board);
|
|
145
|
+
next[rr][cc] = next[r][c]; next[r][c] = null;
|
|
146
|
+
return !inCheck(next, side);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function allLegalMoves(board, side) {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (let r = 0; r < ROWS; r += 1) for (let c = 0; c < COLS; c += 1) {
|
|
153
|
+
if (board[r][c] !== null && sideOf(board[r][c]) === side) {
|
|
154
|
+
for (const [rr, cc] of legalMovesFrom(board, side, r, c)) out.push({ from: [r, c], to: [rr, cc] });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const FILE = 'abcdefghi';
|
|
161
|
+
export const squareName = (r, c) => FILE[c] + String(ROWS - r);
|
|
162
|
+
|
|
163
|
+
/** @returns {{ok: true, state: object, move: object} | {ok: false, code: string}} */
|
|
164
|
+
export function applyMove(game, from, to) {
|
|
165
|
+
if (game.result !== null) return { ok: false, code: 'GAME_OVER' };
|
|
166
|
+
if (!Array.isArray(from) || !Array.isArray(to) || !inBoard(from[0], from[1]) || !inBoard(to[0], to[1])) return { ok: false, code: 'SQUARE_INVALID' };
|
|
167
|
+
const piece = game.board[from[0]][from[1]];
|
|
168
|
+
if (piece === null || sideOf(piece) !== game.turn) return { ok: false, code: 'NOT_YOUR_PIECE' };
|
|
169
|
+
const legal = legalMovesFrom(game.board, game.turn, from[0], from[1]);
|
|
170
|
+
if (!legal.some(([r, c]) => r === to[0] && c === to[1])) return { ok: false, code: 'ILLEGAL_MOVE' };
|
|
171
|
+
const board = cloneBoard(game.board);
|
|
172
|
+
const captured = board[to[0]][to[1]];
|
|
173
|
+
board[to[0]][to[1]] = piece; board[from[0]][from[1]] = null;
|
|
174
|
+
const mover = game.turn;
|
|
175
|
+
const next = { ...game, board, turn: mover === RED ? BLACK : RED, ply: game.ply + 1, history: [...game.history, { from, to, piece, captured }] };
|
|
176
|
+
const opponentHasMoves = allLegalMoves(board, next.turn).length > 0;
|
|
177
|
+
const opponentChecked = inCheck(board, next.turn);
|
|
178
|
+
if (!opponentHasMoves) {
|
|
179
|
+
next.result = { winner: mover, reason: captured !== null && captured.toUpperCase() === 'K' ? 'KING_CAPTURED' : opponentChecked ? 'CHECKMATE' : 'STALEMATE' };
|
|
180
|
+
} else if (opponentChecked) {
|
|
181
|
+
next.check = true;
|
|
182
|
+
} else {
|
|
183
|
+
next.check = false;
|
|
184
|
+
}
|
|
185
|
+
next.lastMove = { from, to, piece, captured, check: opponentChecked === true && next.result === null };
|
|
186
|
+
return { ok: true, state: next, move: next.lastMove };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const GLYPH = { K: '帅', A: '仕', E: '相', H: '马', R: '车', C: '炮', P: '兵', k: '将', a: '士', e: '象', h: '马', r: '车', c: '炮', p: '卒' };
|
|
190
|
+
|
|
191
|
+
export function boardAscii(game) {
|
|
192
|
+
const lines = [' a b c d e f g h i'];
|
|
193
|
+
for (let r = 0; r < ROWS; r += 1) {
|
|
194
|
+
const cells = [];
|
|
195
|
+
for (let c = 0; c < COLS; c += 1) {
|
|
196
|
+
const p = game.board[r][c];
|
|
197
|
+
cells.push(p === null ? '·' : GLYPH[p] ?? p);
|
|
198
|
+
}
|
|
199
|
+
lines.push(String(ROWS - r).padStart(2, ' ') + ' ' + cells.join(' '));
|
|
200
|
+
}
|
|
201
|
+
lines.push(' a b c d e f g h i');
|
|
202
|
+
return lines.join('\n');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function moveText(move) {
|
|
206
|
+
return squareName(move.from[0], move.from[1]) + '-' + squareName(move.to[0], move.to[1]);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function describeProgress(game) {
|
|
210
|
+
const side = game.turn === RED ? '红' : '黑';
|
|
211
|
+
if (game.result !== null) {
|
|
212
|
+
const winner = game.result.winner === RED ? '红方' : '黑方';
|
|
213
|
+
return `第 ${game.ply} 手结束:${winner}胜(${game.result.reason})`;
|
|
214
|
+
}
|
|
215
|
+
const last = game.lastMove ? `上一手 ${moveText(game.lastMove)}${game.lastMove.captured ? ' 吃 ' + GLYPH[game.lastMove.captured] : ''}` : '开局';
|
|
216
|
+
return `第 ${game.ply} 手 · ${side}方行棋 · ${last}${game.check ? ' · 将军!' : ''}`;
|
|
217
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet binding diagnostics (read-only).
|
|
3
|
+
*
|
|
4
|
+
* A device token can be valid while `fleet_id` is null: the relay lists the machine globally
|
|
5
|
+
* ("all machines") but filters it out of every fleet view ("this fleet"). v0.8.7's
|
|
6
|
+
* "already paired -> do not pair again" rule made that state sticky, so it needs to be
|
|
7
|
+
* *observed* rather than inferred from "paired: true".
|
|
8
|
+
*
|
|
9
|
+
* Nothing here writes: the status tool reports, and the auto-start path decides whether to
|
|
10
|
+
* re-admit using the fleet key it already holds.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const FLEET_BINDING_CODE = Object.freeze({
|
|
14
|
+
BOUND: 'FLEET_BOUND',
|
|
15
|
+
UNBOUND: 'FLEET_UNBOUND',
|
|
16
|
+
UNPAIRED: 'FLEET_UNPAIRED',
|
|
17
|
+
TOKEN_REJECTED: 'FLEET_TOKEN_REJECTED',
|
|
18
|
+
UNKNOWN: 'FLEET_UNKNOWN',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Should a start-up re-admit even though a token exists?
|
|
23
|
+
* Only when the token is valid, the machine has no fleet, and a key is available to bind it.
|
|
24
|
+
*/
|
|
25
|
+
export function shouldRejoin({ token, fleetId, fleetKey }) {
|
|
26
|
+
return typeof token === 'string' && token !== ''
|
|
27
|
+
&& (fleetId === null || fleetId === undefined || fleetId === '')
|
|
28
|
+
&& typeof fleetKey === 'string' && fleetKey.trim() !== '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read this machine's relay record and classify its fleet binding.
|
|
33
|
+
*
|
|
34
|
+
* @param {{relayUrl: string, token: string|null, machineId: string|null,
|
|
35
|
+
* fleetKey: string|null, fetchImpl?: Function, timeoutMs?: number}} input
|
|
36
|
+
* @returns {Promise<object>} a bounded, token-free status block
|
|
37
|
+
*/
|
|
38
|
+
export async function diagnoseFleetBinding({ relayUrl, token, machineId, fleetKey = null, fetchImpl = fetch, timeoutMs = 2500 }) {
|
|
39
|
+
const checkedAt = new Date().toISOString();
|
|
40
|
+
const fleetKeyConfigured = typeof fleetKey === 'string' && fleetKey.trim() !== '';
|
|
41
|
+
const base = {
|
|
42
|
+
ok: true,
|
|
43
|
+
state: 'unknown',
|
|
44
|
+
code: FLEET_BINDING_CODE.UNKNOWN,
|
|
45
|
+
fleet_id: null,
|
|
46
|
+
token_present: typeof token === 'string' && token !== '',
|
|
47
|
+
fleet_key_configured: fleetKeyConfigured,
|
|
48
|
+
machine_id: machineId,
|
|
49
|
+
checked_at: checkedAt,
|
|
50
|
+
detail: null,
|
|
51
|
+
remedy: null,
|
|
52
|
+
};
|
|
53
|
+
if (base.token_present !== true) {
|
|
54
|
+
return { ...base, state: 'unpaired', code: FLEET_BINDING_CODE.UNPAIRED, detail: 'this machine has no device token' };
|
|
55
|
+
}
|
|
56
|
+
if (typeof relayUrl !== 'string' || relayUrl === '') {
|
|
57
|
+
return { ...base, detail: 'no relay URL is configured' };
|
|
58
|
+
}
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
61
|
+
try {
|
|
62
|
+
const response = await fetchImpl(relayUrl.replace(/\/+$/, '') + '/v1/devices', {
|
|
63
|
+
headers: { authorization: 'Bearer ' + token, accept: 'application/json' },
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
});
|
|
66
|
+
if (response.status === 401 || response.status === 403) {
|
|
67
|
+
return {
|
|
68
|
+
...base,
|
|
69
|
+
state: 'token_rejected',
|
|
70
|
+
code: FLEET_BINDING_CODE.TOKEN_REJECTED,
|
|
71
|
+
detail: 'the relay rejected this device token (HTTP ' + response.status + ')',
|
|
72
|
+
remedy: 're-pair this machine (the stored token is stale for this relay)',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (response.ok !== true) {
|
|
76
|
+
return { ...base, detail: 'the relay answered HTTP ' + response.status };
|
|
77
|
+
}
|
|
78
|
+
const body = await response.json().catch(() => null);
|
|
79
|
+
const devices = Array.isArray(body?.devices) ? body.devices : [];
|
|
80
|
+
const self = devices.find((entry) => entry?.machine_id === machineId) ?? null;
|
|
81
|
+
if (self === null) {
|
|
82
|
+
return { ...base, detail: 'the relay answered 200 but does not list this machine' };
|
|
83
|
+
}
|
|
84
|
+
const fleetId = typeof self.fleet_id === 'string' && self.fleet_id !== '' ? self.fleet_id : null;
|
|
85
|
+
if (fleetId === null) {
|
|
86
|
+
return {
|
|
87
|
+
...base,
|
|
88
|
+
state: 'unbound',
|
|
89
|
+
code: FLEET_BINDING_CODE.UNBOUND,
|
|
90
|
+
detail: 'the token is valid, but the relay records this machine with fleet_id=null',
|
|
91
|
+
remedy: fleetKeyConfigured
|
|
92
|
+
? 'a fleet key is configured; the next start re-admits this machine into its fleet'
|
|
93
|
+
: 'set fleetKey / W2M_FLEET_KEY, then restart the agent so the machine can join its fleet',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { ...base, state: 'bound', code: FLEET_BINDING_CODE.BOUND, fleet_id: fleetId };
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return {
|
|
99
|
+
...base,
|
|
100
|
+
detail: 'the fleet binding could not be read: ' + (error instanceof Error ? error.message : String(error)),
|
|
101
|
+
};
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { FAILOVER_DEFAULTS, createRelayFailover, relayPool } from './relay-failover.mjs';
|
|
1
|
+
import { FAILOVER_DEFAULTS, createRelayFailover, relayPool } from './relay-failover.mjs';
|
|
2
|
+
import { diagnoseFleetBinding, shouldRejoin } from './fleet-binding.mjs';
|
|
2
3
|
import { DEFAULT_BOOTSTRAP_URL, DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM } from './bootstrap.mjs';
|
|
3
4
|
/**
|
|
4
5
|
* DSH: Windows2MacO - the DSH Cordis plugin.
|
|
@@ -290,7 +291,7 @@ const MAX_STAGES = 16;
|
|
|
290
291
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
291
292
|
* overrides it at runtime for tests and packagers.
|
|
292
293
|
*/
|
|
293
|
-
const PLUGIN_VERSION = '2.0.
|
|
294
|
+
const PLUGIN_VERSION = '2.0.8';
|
|
294
295
|
|
|
295
296
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
296
297
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -4997,6 +4998,13 @@ const result = await request({
|
|
|
4997
4998
|
};
|
|
4998
4999
|
})();
|
|
4999
5000
|
|
|
5001
|
+
const fleetBinding = await diagnoseFleetBinding({
|
|
5002
|
+
relayUrl: cfg.rabbitUrl,
|
|
5003
|
+
token: device.device_token,
|
|
5004
|
+
machineId: device.machine_id,
|
|
5005
|
+
fleetKey: cfg.fleetKey,
|
|
5006
|
+
});
|
|
5007
|
+
|
|
5000
5008
|
return JSON.stringify(
|
|
5001
5009
|
{
|
|
5002
5010
|
ok: true,
|
|
@@ -5020,7 +5028,8 @@ const result = await request({
|
|
|
5020
5028
|
*/
|
|
5021
5029
|
rabbit_source: cfg.rabbitSource,
|
|
5022
5030
|
rabbit_url: cfg.rabbitUrl,
|
|
5023
|
-
p2p: p2pStatus,
|
|
5031
|
+
p2p: p2pStatus,
|
|
5032
|
+
fleet: fleetBinding,
|
|
5024
5033
|
rtt,
|
|
5025
5034
|
config: {
|
|
5026
5035
|
rabbitUrl: cfg.rabbitUrl,
|
|
@@ -5030,7 +5039,8 @@ const result = await request({
|
|
|
5030
5039
|
projectDir: cfg.projectDir,
|
|
5031
5040
|
autoStartAgent: cfg.autoStartAgent,
|
|
5032
5041
|
allowedCommands: cfg.allowedCommands,
|
|
5033
|
-
pairingCode_configured: cfg.pairingCode !== null,
|
|
5042
|
+
pairingCode_configured: cfg.pairingCode !== null,
|
|
5043
|
+
fleetKey_configured: cfg.fleetKey !== null,
|
|
5034
5044
|
p2pMode: cfg.p2pMode,
|
|
5035
5045
|
// The resolved list, shared server first, exactly as the node will query it.
|
|
5036
5046
|
stunServers: cfg.stunServers,
|
|
@@ -8029,6 +8039,18 @@ async function startLocalStack(ctx, cfg) {
|
|
|
8029
8039
|
if (identityOptions.identity !== undefined && identityOptions.identity !== null) identityOptions.identity.device_token = null;
|
|
8030
8040
|
ctx.logger?.warn?.('w2m: stored device token was rejected by ' + relayForToken + ' (HTTP ' + probe.status + '); pairing again with the fleet key');
|
|
8031
8041
|
}
|
|
8042
|
+
if (alreadyPaired && fleetKeyFromConfig !== null) {
|
|
8043
|
+
const binding = await diagnoseFleetBinding({
|
|
8044
|
+
relayUrl: relayForToken,
|
|
8045
|
+
token: existingToken,
|
|
8046
|
+
machineId: loaded.identity.machine_id,
|
|
8047
|
+
fleetKey: fleetKeyFromConfig,
|
|
8048
|
+
});
|
|
8049
|
+
if (shouldRejoin({ token: existingToken, fleetId: binding.fleet_id, fleetKey: fleetKeyFromConfig })) {
|
|
8050
|
+
alreadyPaired = false;
|
|
8051
|
+
ctx.logger?.warn?.('w2m: FLEET_UNBOUND (token valid, fleet_id=null); re-admitting with the configured fleet key');
|
|
8052
|
+
}
|
|
8053
|
+
}
|
|
8032
8054
|
} catch { /* network error: keep the token, do not force a re-pair */ }
|
|
8033
8055
|
}
|
|
8034
8056
|
}
|