theref-sdk 0.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.
@@ -0,0 +1,252 @@
1
+ import { AnyMove, NormalizedMove } from "../types";
2
+
3
+ // ── Universal Move Normalizer ─────────────────────────────────────────────────
4
+ //
5
+ // Converts ANY input format into a human-readable string the AI judge can understand.
6
+ // Handles: strings, numbers, objects, arrays, nested structures — everything.
7
+
8
+ // ── Game-specific adapters ────────────────────────────────────────────────────
9
+
10
+ /** Chess — handles algebraic, UCI, coordinate pairs, and move objects */
11
+ function adaptChess(move: AnyMove): string | null {
12
+ if (typeof move === "string") {
13
+ // Already algebraic notation — passthrough
14
+ if (/^[a-h][1-8]$|^[NBRQK][a-h]?[1-8]?x?[a-h][1-8][+#]?$|^O-O(-O)?[+#]?$/.test(move)) {
15
+ return move;
16
+ }
17
+ }
18
+ if (typeof move === "object" && move !== null && !Array.isArray(move)) {
19
+ const m = move as Record<string, AnyMove>;
20
+ // { from: "e2", to: "e4" } or { from: "e2", to: "e4", promotion: "q" }
21
+ if (m.from && m.to) {
22
+ const promo = m.promotion ? `=${String(m.promotion).toUpperCase()}` : "";
23
+ const piece = m.piece ? `${String(m.piece).toUpperCase()} ` : "";
24
+ return `${piece}${m.from}${m.promotion ? "" : ""}${m.to}${promo}`.trim();
25
+ }
26
+ // { piece: "knight", from: "g1", to: "f3" }
27
+ if (m.piece && m.from && m.to) {
28
+ const pieceMap: Record<string, string> = {
29
+ knight: "N", bishop: "B", rook: "R", queen: "Q", king: "K", pawn: "",
30
+ };
31
+ const p = pieceMap[String(m.piece).toLowerCase()] ?? "";
32
+ return `${p}${m.to}`;
33
+ }
34
+ // UCI format { uci: "e2e4" }
35
+ if (m.uci && typeof m.uci === "string") {
36
+ return `${m.uci.slice(0, 2)}-${m.uci.slice(2, 4)}`;
37
+ }
38
+ }
39
+ // Coordinate array [fromFile, fromRank, toFile, toRank] e.g. [4,1,4,3]
40
+ if (Array.isArray(move) && move.length === 4 && move.every(n => typeof n === "number")) {
41
+ const files = "abcdefgh";
42
+ return `${files[move[0] as number]}${(move[1] as number) + 1}-${files[move[2] as number]}${(move[3] as number) + 1}`;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /** Rock Paper Scissors — normalizes any RPS variant */
48
+ function adaptRPS(move: AnyMove): string | null {
49
+ const choices: Record<string, string> = {
50
+ rock: "Rock", paper: "Paper", scissors: "Scissors",
51
+ r: "Rock", p: "Paper", s: "Scissors",
52
+ "0": "Rock", "1": "Paper", "2": "Scissors",
53
+ stone: "Rock", shears: "Scissors",
54
+ lizard: "Lizard", spock: "Spock", // RPSLS variant
55
+ };
56
+ if (typeof move === "string") {
57
+ const normalized = choices[move.toLowerCase().trim()];
58
+ if (normalized) return normalized;
59
+ }
60
+ if (typeof move === "number") {
61
+ const normalized = choices[String(move)];
62
+ if (normalized) return normalized;
63
+ }
64
+ if (typeof move === "object" && !Array.isArray(move) && move !== null) {
65
+ const m = move as Record<string, AnyMove>;
66
+ const choice = m.choice ?? m.action ?? m.move ?? m.pick;
67
+ if (choice) return adaptRPS(choice);
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /** Combat games — RPG attacks, fighting games, card games */
73
+ function adaptCombat(move: AnyMove): string | null {
74
+ if (typeof move === "object" && !Array.isArray(move) && move !== null) {
75
+ const m = move as Record<string, AnyMove>;
76
+ const parts: string[] = [];
77
+
78
+ // Who is doing what
79
+ if (m.player || m.character || m.unit) {
80
+ parts.push(String(m.player ?? m.character ?? m.unit));
81
+ }
82
+
83
+ // Action
84
+ const action = m.action ?? m.move ?? m.skill ?? m.ability ?? m.attack ?? m.spell ?? m.card;
85
+ if (action) parts.push(`uses ${action}`);
86
+
87
+ // Target
88
+ const target = m.target ?? m.enemy ?? m.opponent;
89
+ if (target) parts.push(`on ${target}`);
90
+
91
+ // Modifiers
92
+ if (m.power !== undefined) parts.push(`(power: ${m.power})`);
93
+ if (m.damage !== undefined) parts.push(`(damage: ${m.damage})`);
94
+ if (m.mana_cost !== undefined) parts.push(`(mana: ${m.mana_cost})`);
95
+ if (m.combo) parts.push(`via combo: ${Array.isArray(m.combo) ? (m.combo as AnyMove[]).join("+") : m.combo}`);
96
+ if (m.type) parts.push(`[${m.type} type]`);
97
+ if (m.element) parts.push(`[${m.element}]`);
98
+ if (m.special) parts.push(`special: ${m.special}`);
99
+ if (m.position) parts.push(`at ${JSON.stringify(m.position)}`);
100
+
101
+ if (parts.length > 0) return parts.join(" ");
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /** Strategy/board game moves */
107
+ function adaptStrategy(move: AnyMove): string | null {
108
+ if (typeof move === "object" && !Array.isArray(move) && move !== null) {
109
+ const m = move as Record<string, AnyMove>;
110
+ const parts: string[] = [];
111
+
112
+ const action = m.action ?? m.command ?? m.order;
113
+ if (action) parts.push(String(action));
114
+
115
+ const unit = m.unit ?? m.piece ?? m.troop ?? m.building;
116
+ if (unit) parts.push(String(unit));
117
+
118
+ if (m.from && m.to) {
119
+ parts.push(`from ${JSON.stringify(m.from)} to ${JSON.stringify(m.to)}`);
120
+ } else if (m.position ?? m.target ?? m.location) {
121
+ parts.push(`at ${JSON.stringify(m.position ?? m.target ?? m.location)}`);
122
+ }
123
+
124
+ if (m.attack) parts.push(`attacking with ${m.attack}`);
125
+ if (m.defend) parts.push(`defending with ${m.defend}`);
126
+ if (m.resource !== undefined) parts.push(`(cost: ${m.resource})`);
127
+
128
+ if (parts.length > 0) return parts.join(" ");
129
+ }
130
+ return null;
131
+ }
132
+
133
+ /** Trivia / Q&A — any answer format */
134
+ function adaptTrivia(move: AnyMove): string | null {
135
+ if (typeof move === "object" && !Array.isArray(move) && move !== null) {
136
+ const m = move as Record<string, AnyMove>;
137
+ const answer = m.answer ?? m.response ?? m.text ?? m.value;
138
+ if (answer !== undefined) return String(answer);
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /** Debate / argument */
144
+ function adaptDebate(move: AnyMove): string | null {
145
+ if (typeof move === "object" && !Array.isArray(move) && move !== null) {
146
+ const m = move as Record<string, AnyMove>;
147
+ const parts: string[] = [];
148
+ if (m.position) parts.push(`Position: ${m.position}`);
149
+ if (m.argument) parts.push(`Argument: ${m.argument}`);
150
+ if (m.evidence) parts.push(`Evidence: ${m.evidence}`);
151
+ if (m.rebuttal) parts.push(`Rebuttal: ${m.rebuttal}`);
152
+ if (parts.length > 0) return parts.join(". ");
153
+ }
154
+ return null;
155
+ }
156
+
157
+ // ── Generic deep object flattener ─────────────────────────────────────────────
158
+
159
+ function flattenObject(obj: Record<string, AnyMove>, prefix = ""): string {
160
+ return Object.entries(obj)
161
+ .map(([key, val]) => {
162
+ const label = prefix ? `${prefix}.${key}` : key;
163
+ if (val === null || val === undefined) return null;
164
+ if (typeof val === "object" && !Array.isArray(val)) {
165
+ return flattenObject(val as Record<string, AnyMove>, label);
166
+ }
167
+ if (Array.isArray(val)) {
168
+ return `${label}: [${(val as AnyMove[]).map(v => String(v)).join(", ")}]`;
169
+ }
170
+ return `${label}: ${val}`;
171
+ })
172
+ .filter(Boolean)
173
+ .join(", ");
174
+ }
175
+
176
+ // ── Main normalizer ───────────────────────────────────────────────────────────
177
+
178
+ export function normalizeMove(move: AnyMove, gameHint?: string): NormalizedMove {
179
+ // 1. Already a string — use directly
180
+ if (typeof move === "string") {
181
+ return { raw: move, text: move.trim(), adapter: "passthrough" };
182
+ }
183
+
184
+ // 2. Number — convert to string
185
+ if (typeof move === "number") {
186
+ return { raw: move, text: String(move), adapter: "number" };
187
+ }
188
+
189
+ // 3. Boolean
190
+ if (typeof move === "boolean") {
191
+ return { raw: move, text: move ? "Yes" : "No", adapter: "boolean" };
192
+ }
193
+
194
+ // 4. Null/undefined
195
+ if (move === null || move === undefined) {
196
+ return { raw: move as AnyMove, text: "No move", adapter: "null" };
197
+ }
198
+
199
+ const hint = gameHint?.toLowerCase() ?? "";
200
+
201
+ // 5. Try game-specific adapters based on hint
202
+ if (hint.includes("chess")) {
203
+ const result = adaptChess(move);
204
+ if (result) return { raw: move, text: result, adapter: "chess" };
205
+ }
206
+ if (hint.includes("rock") || hint.includes("rps") || hint.includes("scissors")) {
207
+ const result = adaptRPS(move);
208
+ if (result) return { raw: move, text: result, adapter: "rps" };
209
+ }
210
+ if (hint.includes("trivia") || hint.includes("quiz") || hint.includes("question")) {
211
+ const result = adaptTrivia(move);
212
+ if (result) return { raw: move, text: result, adapter: "trivia" };
213
+ }
214
+ if (hint.includes("debate") || hint.includes("argument")) {
215
+ const result = adaptDebate(move);
216
+ if (result) return { raw: move, text: result, adapter: "debate" };
217
+ }
218
+
219
+ // 6. Auto-detect from structure — try all adapters
220
+ if (typeof move === "object") {
221
+ const chess = adaptChess(move);
222
+ if (chess) return { raw: move, text: chess, adapter: "chess" };
223
+
224
+ const rps = adaptRPS(move);
225
+ if (rps) return { raw: move, text: rps, adapter: "rps" };
226
+
227
+ const combat = adaptCombat(move);
228
+ if (combat) return { raw: move, text: combat, adapter: "combat" };
229
+
230
+ const strategy = adaptStrategy(move);
231
+ if (strategy) return { raw: move, text: strategy, adapter: "strategy" };
232
+
233
+ const trivia = adaptTrivia(move);
234
+ if (trivia) return { raw: move, text: trivia, adapter: "trivia" };
235
+
236
+ const debate = adaptDebate(move);
237
+ if (debate) return { raw: move, text: debate, adapter: "debate" };
238
+
239
+ // 7. Array — join elements
240
+ if (Array.isArray(move)) {
241
+ const text = (move as AnyMove[]).map(m => normalizeMove(m).text).join(", ");
242
+ return { raw: move, text, adapter: "array" };
243
+ }
244
+
245
+ // 8. Generic object — flatten to readable string
246
+ const flat = flattenObject(move as Record<string, AnyMove>);
247
+ return { raw: move, text: flat, adapter: "generic-object" };
248
+ }
249
+
250
+ // 9. Absolute fallback
251
+ return { raw: move, text: String(move), adapter: "fallback" };
252
+ }
@@ -0,0 +1,444 @@
1
+ import { createClient, createAccount, generatePrivateKey, chains } from "genlayer-js";
2
+ import { TransactionStatus } from "genlayer-js/types";
3
+ import {
4
+ TheRefConfig, NetworkConfig, GameState, GameStatus,
5
+ CreateGameOptions, JudgmentResult, RoundResult,
6
+ CreateTournamentOptions, Tournament, BracketMatch,
7
+ LeaderboardEntry, PlayerStats, PlayerType, AnyMove,
8
+ } from "../types";
9
+ import { resolveNetwork, gidToNum, DEFAULT_WS_URL } from "../utils/networks";
10
+ import { normalizeMove } from "../adapters/normalizer";
11
+
12
+ // ── Client ────────────────────────────────────────────────────────────────────
13
+
14
+ export class TheRefClient {
15
+ private network: NetworkConfig;
16
+ private glClient: ReturnType<typeof createClient>;
17
+ private retries: number;
18
+ private pollInterval: number;
19
+ private wsUrl: string;
20
+
21
+ constructor(config: TheRefConfig) {
22
+ this.network = resolveNetwork(config.network);
23
+ this.retries = config.retries ?? 300;
24
+ this.pollInterval = config.pollInterval ?? 5000;
25
+ this.wsUrl = config.wsUrl ?? DEFAULT_WS_URL;
26
+
27
+ // Select the correct built-in chain
28
+ const chain = this.network.chainId === 4221
29
+ ? chains.testnetBradbury
30
+ : chains.studionet;
31
+
32
+ // Build account — private key for agents, wallet address for browser
33
+ let account: any;
34
+ if (config.privateKey) {
35
+ account = createAccount(config.privateKey);
36
+ } else if (config.walletAddress) {
37
+ account = config.walletAddress; // SDK routes through window.ethereum
38
+ } else {
39
+ // Dev mode — generate ephemeral key
40
+ account = createAccount(generatePrivateKey());
41
+ }
42
+
43
+ this.glClient = createClient({
44
+ chain,
45
+ endpoint: this.network.rpc,
46
+ account,
47
+ });
48
+ }
49
+
50
+ // ── Internal helpers ────────────────────────────────────────────────────────
51
+
52
+ private async write(
53
+ address: string,
54
+ method: string,
55
+ args: any[],
56
+ value: bigint = 0n,
57
+ ): Promise<{ payload: string; txHash: string }> {
58
+ const tx = await this.glClient.writeContract({
59
+ address: address as `0x${string}`,
60
+ functionName: method,
61
+ args: args as any,
62
+ value,
63
+ });
64
+
65
+ const receipt = await this.glClient.waitForTransactionReceipt({
66
+ hash: tx as any,
67
+ status: TransactionStatus.ACCEPTED,
68
+ retries: this.retries,
69
+ });
70
+
71
+ const leader = (receipt as any)?.consensus_data?.leader_receipt?.[0];
72
+ const payload = String(
73
+ leader?.result?.payload?.readable ??
74
+ (receipt as any)?.data?.result ?? ""
75
+ ).replace(/^"|"$/g, "");
76
+
77
+ return { payload, txHash: String(tx) };
78
+ }
79
+
80
+ private async read(
81
+ address: string,
82
+ method: string,
83
+ args: any[] = [],
84
+ ): Promise<unknown> {
85
+ return this.glClient.readContract({
86
+ address: address as `0x${string}`,
87
+ functionName: method,
88
+ args: args as any,
89
+ });
90
+ }
91
+
92
+ private get CORE() { return this.network.addresses.CORE; }
93
+ private get LB() { return this.network.addresses.LB; }
94
+ private get TRN() { return this.network.addresses.TRN; }
95
+
96
+ private mapGameState(raw: any): GameState {
97
+ return {
98
+ gameId: raw.game_id ?? "",
99
+ gameName: raw.game_name ?? "",
100
+ status: raw.status ?? "waiting",
101
+ player1: raw.player1 ?? "",
102
+ player2: raw.player2 ?? "",
103
+ agent1: raw.agent1 ?? "",
104
+ agent2: raw.agent2 ?? "",
105
+ maxRounds: Number(raw.max_rounds ?? 0),
106
+ roundCount: Number(raw.round_count ?? 0),
107
+ judgedThrough: Number(raw.judged_through ?? 0),
108
+ rules: raw.rules ?? "",
109
+ winner: raw.winner ?? "",
110
+ score: raw.score ?? {},
111
+ playerTypes: raw.player_types ?? {},
112
+ caller: raw.caller ?? "",
113
+ rounds: (raw.rounds ?? []).map((r: any) => ({
114
+ roundNumber: r.round_number,
115
+ movePlayer1: r.move_player1 ?? "",
116
+ movePlayer2: r.move_player2 ?? "",
117
+ result: r.result ?? "pending",
118
+ reasonType: r.reason_type ?? "normal",
119
+ invalidPlayer: r.invalid_player ?? "none",
120
+ reasoning: r.reasoning ?? "",
121
+ confidence: Number(r.confidence ?? 0),
122
+ status: r.status ?? "pending",
123
+ })),
124
+ };
125
+ }
126
+
127
+ // ── Game API ────────────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Create a new game. Returns the game ID.
131
+ */
132
+ async createGame(options: CreateGameOptions): Promise<string> {
133
+ const { payload } = await this.write(this.CORE, "start_game", [
134
+ options.name,
135
+ options.visibility ?? "public",
136
+ options.maxRounds ?? 3,
137
+ options.rules ?? "",
138
+ options.player1,
139
+ options.player2 ?? "",
140
+ options.agent1 ?? 0,
141
+ options.agent2 ?? 0,
142
+ ]);
143
+ return payload;
144
+ }
145
+
146
+ /**
147
+ * Submit a move for a player. Accepts ANY move format — string, object, array, etc.
148
+ * The SDK automatically normalizes it to a string the AI judge can understand.
149
+ */
150
+ async submitMove(
151
+ gameId: string,
152
+ playerName: string,
153
+ move: AnyMove,
154
+ gameHint?: string, // optional game name hint for better normalization
155
+ ): Promise<{ txHash: string; normalizedMove: string }> {
156
+ const normalized = normalizeMove(move, gameHint);
157
+ const { txHash } = await this.write(this.CORE, "submit_move", [
158
+ gidToNum(gameId),
159
+ playerName,
160
+ normalized.text,
161
+ ]);
162
+ return { txHash, normalizedMove: normalized.text };
163
+ }
164
+
165
+ /**
166
+ * Judge the game — triggers AI consensus on all pending rounds.
167
+ */
168
+ async judgeGame(gameId: string): Promise<JudgmentResult> {
169
+ const { payload, txHash } = await this.write(
170
+ this.CORE, "judge_game", [gidToNum(gameId)]
171
+ );
172
+ const state = await this.getGameState(gameId);
173
+ return {
174
+ winner: state.winner,
175
+ isDraw: state.status === "draw",
176
+ score: state.score,
177
+ rounds: state.rounds,
178
+ txHash,
179
+ payload,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * End an open-ended game (max_rounds = 0). Only callable by the game creator.
185
+ */
186
+ async endGame(gameId: string): Promise<JudgmentResult> {
187
+ const { payload, txHash } = await this.write(
188
+ this.CORE, "end_game", [gidToNum(gameId)]
189
+ );
190
+ const state = await this.getGameState(gameId);
191
+ return {
192
+ winner: state.winner,
193
+ isDraw: state.status === "draw",
194
+ score: state.score,
195
+ rounds: state.rounds,
196
+ txHash,
197
+ payload,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Get the full game state.
203
+ */
204
+ async getGameState(gameId: string): Promise<GameState> {
205
+ const raw = await this.read(this.CORE, "get_game_state", [gameId]);
206
+ return this.mapGameState(raw);
207
+ }
208
+
209
+ /**
210
+ * Get all active games.
211
+ */
212
+ async getActiveGames(): Promise<GameState[]> {
213
+ const raw = await this.read(this.CORE, "get_active_games") as any[];
214
+ return (raw ?? []).map(this.mapGameState);
215
+ }
216
+
217
+ /**
218
+ * Get total number of games.
219
+ */
220
+ async getTotalGames(): Promise<number> {
221
+ const result = await this.read(this.CORE, "get_total_games");
222
+ return Number(result ?? 0);
223
+ }
224
+
225
+ // ── Polling helpers ─────────────────────────────────────────────────────────
226
+
227
+ /**
228
+ * Wait until both players have submitted their move for a given round.
229
+ * Polls every `pollInterval` ms.
230
+ */
231
+ async waitForBothMoves(gameId: string, roundNumber: number): Promise<RoundResult> {
232
+ while (true) {
233
+ const state = await this.getGameState(gameId);
234
+ const round = state.rounds.find(r => r.roundNumber === roundNumber);
235
+ if (round?.movePlayer1 && round?.movePlayer2) return round;
236
+ await this.sleep(this.pollInterval);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Wait until the opponent has submitted their move for a round.
242
+ * isPlayer1: true if you are player1, false if player2.
243
+ */
244
+ async waitForOpponentMove(
245
+ gameId: string,
246
+ roundNumber: number,
247
+ isPlayer1: boolean,
248
+ ): Promise<string> {
249
+ while (true) {
250
+ const state = await this.getGameState(gameId);
251
+ const round = state.rounds.find(r => r.roundNumber === roundNumber);
252
+ const opponentMove = isPlayer1 ? round?.movePlayer2 : round?.movePlayer1;
253
+ if (opponentMove) return opponentMove;
254
+ await this.sleep(this.pollInterval);
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Wait until the game reaches a specific status.
260
+ */
261
+ async waitForStatus(gameId: string, status: GameStatus): Promise<GameState> {
262
+ while (true) {
263
+ const state = await this.getGameState(gameId);
264
+ if (state.status === status) return state;
265
+ if (state.status === "completed" || state.status === "draw") return state;
266
+ await this.sleep(this.pollInterval);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Play a full game automatically.
272
+ * moveFn is called for each round — return your move in any format.
273
+ */
274
+ async playGame(
275
+ gameId: string,
276
+ playerName: string,
277
+ isPlayer1: boolean,
278
+ moveFn: (state: GameState, round: number) => Promise<AnyMove>,
279
+ gameHint?: string,
280
+ ): Promise<JudgmentResult> {
281
+ let state = await this.getGameState(gameId);
282
+ const maxRounds = state.maxRounds || 999;
283
+
284
+ for (let round = 1; round <= maxRounds; round++) {
285
+ state = await this.getGameState(gameId);
286
+ if (state.status !== "active") break;
287
+
288
+ // Wait for opponent first if we're player2
289
+ if (!isPlayer1) {
290
+ await this.waitForOpponentMove(gameId, round, false);
291
+ }
292
+
293
+ // Generate and submit our move
294
+ state = await this.getGameState(gameId);
295
+ const move = await moveFn(state, round);
296
+ await this.submitMove(gameId, playerName, move, gameHint);
297
+
298
+ // Wait for opponent if we're player1
299
+ if (isPlayer1) {
300
+ await this.waitForOpponentMove(gameId, round, true);
301
+ }
302
+
303
+ // Check if max rounds reached
304
+ state = await this.getGameState(gameId);
305
+ if (state.roundCount >= maxRounds) break;
306
+ }
307
+
308
+ return this.judgeGame(gameId);
309
+ }
310
+
311
+ // ── Leaderboard API ─────────────────────────────────────────────────────────
312
+
313
+ async getLeaderboard(
314
+ gameName: string,
315
+ playerType: PlayerType | "all" = "all",
316
+ ): Promise<LeaderboardEntry[]> {
317
+ const raw = await this.read(this.LB, "get_leaderboard", [gameName, playerType]) as any[];
318
+ return (raw ?? []).map(e => ({
319
+ player: e.player ?? "",
320
+ wins: Number(e.wins ?? 0),
321
+ losses: Number(e.losses ?? 0),
322
+ draws: Number(e.draws ?? 0),
323
+ score: Number(e.score ?? 0),
324
+ playerType: (e.player_type ?? "human") as PlayerType,
325
+ }));
326
+ }
327
+
328
+ async getTopPlayers(gameName: string, n = 10): Promise<LeaderboardEntry[]> {
329
+ const raw = await this.read(this.LB, "get_top_players", [gameName, n]) as any[];
330
+ return (raw ?? []).map(e => ({
331
+ player: e.player ?? "",
332
+ wins: Number(e.wins ?? 0),
333
+ losses: Number(e.losses ?? 0),
334
+ draws: Number(e.draws ?? 0),
335
+ score: Number(e.score ?? 0),
336
+ playerType: (e.player_type ?? "human") as PlayerType,
337
+ }));
338
+ }
339
+
340
+ async getPlayerStats(
341
+ gameName: string,
342
+ playerName: string,
343
+ playerType: PlayerType | "all" = "all",
344
+ ): Promise<PlayerStats | null> {
345
+ try {
346
+ const raw = await this.read(this.LB, "get_player_stats", [gameName, playerName, playerType]) as any;
347
+ if (!raw) return null;
348
+ return {
349
+ player: raw.player ?? playerName,
350
+ wins: Number(raw.wins ?? 0),
351
+ losses: Number(raw.losses ?? 0),
352
+ draws: Number(raw.draws ?? 0),
353
+ score: Number(raw.score ?? 0),
354
+ games: Number(raw.games ?? 0),
355
+ playerType: (raw.player_type ?? playerType) as PlayerType,
356
+ };
357
+ } catch { return null; }
358
+ }
359
+
360
+ // ── Tournament API ──────────────────────────────────────────────────────────
361
+
362
+ async createTournament(options: CreateTournamentOptions): Promise<string> {
363
+ const { payload } = await this.write(this.TRN, "create_tournament", [
364
+ options.name,
365
+ options.gameName,
366
+ options.format,
367
+ options.maxPlayers,
368
+ 0, // entry fee — always free
369
+ options.prizeSplit ?? [70, 30],
370
+ options.rules ?? "",
371
+ options.roundsPerMatch ?? 1,
372
+ ]);
373
+ return payload;
374
+ }
375
+
376
+ async joinTournament(
377
+ tid: string,
378
+ playerName: string,
379
+ playerType: PlayerType = "human",
380
+ ): Promise<void> {
381
+ await this.write(this.TRN, "join_tournament", [tid, playerName, playerType]);
382
+ }
383
+
384
+ async startTournament(tid: string): Promise<void> {
385
+ await this.write(this.TRN, "start_tournament", [tid]);
386
+ }
387
+
388
+ async getTournament(tid: string): Promise<Tournament | null> {
389
+ try {
390
+ const raw = await this.read(this.TRN, "get_tournament", [tid]) as any;
391
+ if (!raw) return null;
392
+ return {
393
+ tid: raw.tid ?? tid,
394
+ name: raw.name ?? "",
395
+ gameName: raw.game_name ?? "",
396
+ format: raw.format ?? "",
397
+ maxPlayers: Number(raw.max_players ?? 0),
398
+ status: raw.status ?? "",
399
+ players: raw.players ?? [],
400
+ bracket: (raw.bracket ?? []).map((m: any) => ({
401
+ matchId: m.match_id,
402
+ round: m.round,
403
+ player1: m.player1,
404
+ player2: m.player2,
405
+ gameId: m.game_id,
406
+ winner: m.winner,
407
+ status: m.status,
408
+ })),
409
+ winner: raw.winner ?? "",
410
+ };
411
+ } catch { return null; }
412
+ }
413
+
414
+ async listTournaments(): Promise<Tournament[]> {
415
+ const raw = await this.read(this.TRN, "list_tournaments") as any[];
416
+ return (raw ?? []).map(t => ({
417
+ tid: t.tid ?? "",
418
+ name: t.name ?? "",
419
+ gameName: t.game_name ?? "",
420
+ format: t.format ?? "",
421
+ maxPlayers: Number(t.max_players ?? 0),
422
+ status: t.status ?? "",
423
+ players: t.players ?? [],
424
+ bracket: [],
425
+ winner: t.winner ?? "",
426
+ }));
427
+ }
428
+
429
+ async recordMatchResult(tid: string, matchId: number, winner: string): Promise<void> {
430
+ await this.write(this.TRN, "record_match_result", [tid, matchId, winner]);
431
+ }
432
+
433
+ // ── Utils ───────────────────────────────────────────────────────────────────
434
+
435
+ /** Normalize any move to a string without submitting */
436
+ normalizeMove(move: AnyMove, gameHint?: string) {
437
+ return normalizeMove(move, gameHint);
438
+ }
439
+
440
+ /** Sleep helper */
441
+ private sleep(ms: number) {
442
+ return new Promise(resolve => setTimeout(resolve, ms));
443
+ }
444
+ }