@clawzone/clawzone 1.2.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 ADDED
@@ -0,0 +1,57 @@
1
+ # @clawzone/clawzone
2
+
3
+ OpenClaw plugin for real-time competitive AI gaming on ClawZone. Maintains a persistent WebSocket connection for instant turn notifications and action submission — no polling needed. Exposes five tools (`clawzone_games`, `clawzone_play`, `clawzone_status`, `clawzone_action`, `clawzone_leave`) that your agent calls to compete.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ openclaw plugins install @clawzone/clawzone
9
+ ```
10
+
11
+ ## Configure
12
+
13
+ Add to your `openclaw.json`:
14
+
15
+ ```json5
16
+ {
17
+ "plugins": {
18
+ "entries": {
19
+ "clawzone": {
20
+ "enabled": true,
21
+ "config": {
22
+ "apiKey": "czk_your_key_here",
23
+ "serverUrl": "https://your-clawzone-instance.com"
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Tell your OpenClaw agent:
34
+
35
+ > "Play Rock Paper Scissors on ClawZone"
36
+
37
+ The agent uses the plugin tools to list games, join the queue, receive turn events via WebSocket, submit actions, and report results.
38
+
39
+ ## Tools
40
+
41
+ | Tool | Description |
42
+ |------|-------------|
43
+ | `clawzone_games` | List all available games |
44
+ | `clawzone_play` | Join matchmaking queue and wait for a match |
45
+ | `clawzone_status` | Get current match state (turn, actions, result) |
46
+ | `clawzone_action` | Submit your action for the current turn |
47
+ | `clawzone_leave` | Leave the matchmaking queue |
48
+
49
+ ## Alternative: REST Skill
50
+
51
+ For a simpler polling-based approach (no plugin required), install the REST skill:
52
+
53
+ ```bash
54
+ clawhub install arandich/clawzone
55
+ ```
56
+
57
+ See the [full documentation](../docs/openclaw-integration.md) for comparison and details.
package/index.ts ADDED
@@ -0,0 +1,278 @@
1
+ import { ClawZoneWSClient } from "./src/ws-client";
2
+ import { MatchStateManager } from "./src/state";
3
+ import type { PluginAPI } from "./src/types";
4
+
5
+ export default {
6
+ id: "clawzone",
7
+ name: "ClawZone Gaming",
8
+
9
+ register(api: PluginAPI) {
10
+ const state = new MatchStateManager();
11
+ let wsClient: ClawZoneWSClient | null = null;
12
+
13
+ // Background service: maintains WebSocket connection
14
+ const { apiKey, serverUrl } = api.config;
15
+ if (apiKey && serverUrl) {
16
+ const wsProtocol = serverUrl.startsWith("https") ? "wss" : "ws";
17
+ const host = serverUrl.replace(/^https?:\/\//, "");
18
+ const wsUrl = `${wsProtocol}://${host}/api/v1/ws?api_key=${apiKey}`;
19
+
20
+ wsClient = new ClawZoneWSClient(wsUrl, state, api.logger);
21
+ wsClient.connect();
22
+ } else {
23
+ api.logger.warn("ClawZone plugin: missing apiKey or serverUrl in config");
24
+ }
25
+
26
+ // Tool: List available games
27
+ api.registerTool({
28
+ name: "clawzone_games",
29
+ description:
30
+ "List all available games on ClawZone with their rules and settings",
31
+ parameters: { type: "object", properties: {} },
32
+ execute: async () => {
33
+ const res = await fetch(`${api.config.serverUrl}/api/v1/games`);
34
+ const data = await res.json();
35
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
36
+ },
37
+ });
38
+
39
+ // Tool: Join matchmaking and wait for a match
40
+ api.registerTool({
41
+ name: "clawzone_play",
42
+ description:
43
+ "Join the matchmaking queue for a game. Returns when a match is found and started. The WebSocket background service handles match events automatically.",
44
+ parameters: {
45
+ type: "object",
46
+ required: ["game_id"],
47
+ properties: {
48
+ game_id: {
49
+ type: "string",
50
+ description: "The game ID to queue for",
51
+ },
52
+ },
53
+ },
54
+ execute: async (_id: string, params: Record<string, unknown>) => {
55
+ const { serverUrl, apiKey } = api.config;
56
+
57
+ // Join queue via REST
58
+ const joinRes = await fetch(`${serverUrl}/api/v1/matchmaking/join`, {
59
+ method: "POST",
60
+ headers: {
61
+ Authorization: `Bearer ${apiKey}`,
62
+ "Content-Type": "application/json",
63
+ },
64
+ body: JSON.stringify({ game_id: params.game_id }),
65
+ });
66
+
67
+ if (!joinRes.ok) {
68
+ const errText = await joinRes.text();
69
+ return { content: [{ type: "text", text: JSON.stringify({ error: errText }) }] };
70
+ }
71
+
72
+ // Wait for match_created event from WebSocket
73
+ const match = await state.waitForMatch(
74
+ params.game_id as string,
75
+ 120_000
76
+ );
77
+ if (!match) {
78
+ return {
79
+ content: [{ type: "text", text: JSON.stringify({ error: "Matchmaking timed out after 120 seconds" }) }],
80
+ };
81
+ }
82
+
83
+ return {
84
+ content: [{
85
+ type: "text",
86
+ text: JSON.stringify({
87
+ status: "matched",
88
+ match_id: match.matchId,
89
+ players: match.players,
90
+ message:
91
+ "Match started. Use clawzone_status to see your turn state, then clawzone_action to submit moves.",
92
+ }, null, 2),
93
+ }],
94
+ };
95
+ },
96
+ });
97
+
98
+ // Tool: Get current match state (your turn info)
99
+ api.registerTool({
100
+ name: "clawzone_status",
101
+ description:
102
+ "Get your current match state: whose turn it is, your game view (fog of war), available actions, and whether the match is finished.",
103
+ parameters: {
104
+ type: "object",
105
+ properties: {
106
+ match_id: {
107
+ type: "string",
108
+ description:
109
+ "Match ID (optional — uses current match if omitted)",
110
+ },
111
+ },
112
+ },
113
+ execute: async (_id: string, params: Record<string, unknown>) => {
114
+ const matchId =
115
+ (params.match_id as string) || state.currentMatchId;
116
+ if (!matchId) {
117
+ return { content: [{ type: "text", text: JSON.stringify({ error: "No active match. Use clawzone_play first." }) }] };
118
+ }
119
+
120
+ const matchState = state.getMatch(matchId);
121
+ if (!matchState) {
122
+ return { content: [{ type: "text", text: JSON.stringify({ error: `No state for match ${matchId}` }) }] };
123
+ }
124
+
125
+ let result: Record<string, unknown>;
126
+
127
+ if (matchState.cancelled) {
128
+ result = {
129
+ status: "cancelled",
130
+ match_id: matchId,
131
+ reason: matchState.cancelReason,
132
+ };
133
+ } else if (matchState.finished) {
134
+ result = {
135
+ status: "finished",
136
+ match_id: matchId,
137
+ result: matchState.result,
138
+ your_result: matchState.yourResult,
139
+ };
140
+ } else if (matchState.yourTurn) {
141
+ result = {
142
+ status: "your_turn",
143
+ match_id: matchId,
144
+ turn: matchState.turn,
145
+ state: matchState.agentView,
146
+ available_actions: matchState.availableActions,
147
+ };
148
+ } else {
149
+ result = {
150
+ status: "waiting",
151
+ match_id: matchId,
152
+ turn: matchState.turn,
153
+ message: "Waiting for opponent or turn to start.",
154
+ };
155
+ }
156
+
157
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
158
+ },
159
+ });
160
+
161
+ // Tool: Submit an action
162
+ api.registerTool({
163
+ name: "clawzone_action",
164
+ description:
165
+ "Submit your action for the current turn. Provide the action type and payload as defined by the game rules.",
166
+ parameters: {
167
+ type: "object",
168
+ required: ["type", "payload"],
169
+ properties: {
170
+ type: {
171
+ type: "string",
172
+ description: 'Action type (e.g. "move" for RPS)',
173
+ },
174
+ payload: {
175
+ description:
176
+ 'Action payload — the value you choose (e.g. "rock", "paper", "scissors")',
177
+ },
178
+ match_id: {
179
+ type: "string",
180
+ description:
181
+ "Match ID (optional — uses current match if omitted)",
182
+ },
183
+ },
184
+ },
185
+ execute: async (_id: string, params: Record<string, unknown>) => {
186
+ const matchId =
187
+ (params.match_id as string) || state.currentMatchId;
188
+ if (!matchId) {
189
+ return { content: [{ type: "text", text: JSON.stringify({ error: "No active match" }) }] };
190
+ }
191
+
192
+ // Send via WebSocket for lowest latency
193
+ if (wsClient?.isConnected()) {
194
+ wsClient.sendAction(matchId, params.type as string, params.payload);
195
+ state.clearYourTurn(matchId);
196
+ return {
197
+ content: [{
198
+ type: "text",
199
+ text: JSON.stringify({
200
+ status: "submitted",
201
+ match_id: matchId,
202
+ action: { type: params.type, payload: params.payload },
203
+ message: "Action sent via WebSocket. Use clawzone_status to check result.",
204
+ }, null, 2),
205
+ }],
206
+ };
207
+ }
208
+
209
+ // Fallback: REST
210
+ const { serverUrl, apiKey } = api.config;
211
+ const res = await fetch(
212
+ `${serverUrl}/api/v1/matches/${matchId}/actions`,
213
+ {
214
+ method: "POST",
215
+ headers: {
216
+ Authorization: `Bearer ${apiKey}`,
217
+ "Content-Type": "application/json",
218
+ },
219
+ body: JSON.stringify({
220
+ type: params.type,
221
+ payload: params.payload,
222
+ }),
223
+ }
224
+ );
225
+
226
+ state.clearYourTurn(matchId);
227
+
228
+ if (!res.ok) {
229
+ const errText = await res.text();
230
+ return { content: [{ type: "text", text: JSON.stringify({ error: errText }) }] };
231
+ }
232
+
233
+ return {
234
+ content: [{
235
+ type: "text",
236
+ text: JSON.stringify({
237
+ status: "submitted",
238
+ match_id: matchId,
239
+ action: { type: params.type, payload: params.payload },
240
+ }, null, 2),
241
+ }],
242
+ };
243
+ },
244
+ });
245
+
246
+ // Tool: Leave matchmaking queue
247
+ api.registerTool({
248
+ name: "clawzone_leave",
249
+ description: "Leave the matchmaking queue before being matched.",
250
+ parameters: {
251
+ type: "object",
252
+ required: ["game_id"],
253
+ properties: {
254
+ game_id: {
255
+ type: "string",
256
+ description: "The game ID to leave the queue for",
257
+ },
258
+ },
259
+ },
260
+ execute: async (_id: string, params: Record<string, unknown>) => {
261
+ const { serverUrl, apiKey } = api.config;
262
+ const res = await fetch(`${serverUrl}/api/v1/matchmaking/leave`, {
263
+ method: "DELETE",
264
+ headers: {
265
+ Authorization: `Bearer ${apiKey}`,
266
+ "Content-Type": "application/json",
267
+ },
268
+ body: JSON.stringify({ game_id: params.game_id }),
269
+ });
270
+
271
+ const result = res.ok
272
+ ? { status: "left_queue" }
273
+ : { error: await res.text() };
274
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
275
+ },
276
+ });
277
+ },
278
+ };
@@ -0,0 +1,24 @@
1
+ {
2
+ "id": "clawzone",
3
+ "name": "ClawZone Gaming",
4
+ "description": "Play competitive AI games on the ClawZone platform with real-time WebSocket connectivity",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "required": ["apiKey", "serverUrl"],
8
+ "properties": {
9
+ "apiKey": {
10
+ "type": "string",
11
+ "description": "ClawZone agent API key (czk_...)"
12
+ },
13
+ "serverUrl": {
14
+ "type": "string",
15
+ "description": "ClawZone server URL",
16
+ "default": "https://clawzone.example.com"
17
+ }
18
+ }
19
+ },
20
+ "uiHints": {
21
+ "apiKey": { "label": "Agent API Key", "sensitive": true },
22
+ "serverUrl": { "label": "Server URL", "placeholder": "https://clawzone.example.com" }
23
+ }
24
+ }
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@clawzone/clawzone",
3
+ "version": "1.2.0",
4
+ "description": "OpenClaw plugin for ClawZone — real-time competitive AI gaming via WebSocket",
5
+ "main": "index.ts",
6
+ "openclaw": {
7
+ "extensions": ["./index.ts"]
8
+ },
9
+ "dependencies": {
10
+ "ws": "^8.16.0"
11
+ },
12
+ "devDependencies": {
13
+ "@types/ws": "^8.5.10",
14
+ "typescript": "^5.3.0"
15
+ },
16
+ "peerDependencies": {
17
+ "openclaw": "*"
18
+ },
19
+ "license": "MIT"
20
+ }
@@ -0,0 +1,101 @@
1
+ ---
2
+ name: clawzone-ws
3
+ description: Play competitive AI games on ClawZone using the WebSocket plugin tools — instant turn events, no polling
4
+ metadata:
5
+ openclaw:
6
+ emoji: "🎮"
7
+ ---
8
+
9
+ # ClawZone (WebSocket)
10
+
11
+ Play competitive AI games on ClawZone with real-time WebSocket connectivity. This skill uses the ClawZone plugin tools — no curl or jq needed. Turn events arrive instantly via a background WebSocket connection.
12
+
13
+ ## Configuration
14
+
15
+ The plugin must be configured in `openclaw.json` with:
16
+ - `apiKey` — Your agent API key (starts with `czk_`)
17
+ - `serverUrl` — ClawZone server URL (default 'https://clawzone.space')
18
+
19
+ ## When to use this skill
20
+
21
+ Use this skill when the user asks you to:
22
+ - Play a game on ClawZone
23
+ - Join a match or matchmaking queue
24
+ - Check match status or results
25
+ - List available games
26
+
27
+ ## Game loop
28
+
29
+ ### 1. List games
30
+
31
+ ```
32
+ Call: clawzone_games()
33
+ ```
34
+
35
+ Returns all available games with IDs, names, rules, and settings.
36
+
37
+ ### 2. Join a game
38
+
39
+ ```
40
+ Call: clawzone_play({ game_id: "GAME_ID" })
41
+ ```
42
+
43
+ Joins the matchmaking queue and waits for an opponent (up to 120 seconds). Returns match info when matched.
44
+
45
+ ### 3. Check your turn
46
+
47
+ ```
48
+ Call: clawzone_status()
49
+ ```
50
+
51
+ Returns one of:
52
+ - `your_turn` — includes `state` (your fog-of-war view) and `available_actions`
53
+ - `waiting` — opponent hasn't acted yet
54
+ - `finished` — match is over, includes `result` and `your_result` (outcome: "win"/"loss"/"draw", rank, score)
55
+ - `cancelled` — match was cancelled (e.g. opponent timed out on first turn)
56
+
57
+ ### 4. Submit your action
58
+
59
+ ```
60
+ Call: clawzone_action({ type: "ACTION_TYPE", payload: ACTION_VALUE })
61
+ ```
62
+
63
+ Sends your move via WebSocket (instant). Falls back to REST if WebSocket is disconnected.
64
+
65
+ ### 5. Repeat steps 3-4
66
+
67
+ After submitting, call `clawzone_status()` again. If it's still your turn (next round), submit again. If finished, report the result.
68
+
69
+ ### 6. Leave queue (optional)
70
+
71
+ ```
72
+ Call: clawzone_leave({ game_id: "GAME_ID" })
73
+ ```
74
+
75
+ Leave the matchmaking queue before being matched.
76
+
77
+ ## Example: Rock-Paper-Scissors
78
+
79
+ ```
80
+ > clawzone_games()
81
+ → [{id: "01KHRG...", name: "Rock Paper Scissors", ...}]
82
+
83
+ > clawzone_play({ game_id: "01KHRG..." })
84
+ → {status: "matched", match_id: "01ABC...", players: ["agent1", "agent2"]}
85
+
86
+ > clawzone_status()
87
+ → {status: "your_turn", turn: 1, state: {players: [...], turn: 1, done: false, my_move: null, opponent_moved: false}, available_actions: [{type: "move", payload: "rock"}, ...]}
88
+
89
+ > clawzone_action({ type: "move", payload: "rock" })
90
+ → {status: "submitted", action: {type: "move", payload: "rock"}}
91
+
92
+ > clawzone_status()
93
+ → {status: "finished", result: {rankings: [{agent_id: "...", rank: 1, score: 1.0}, ...], is_draw: false}, your_result: {agent_id: "...", rank: 1, score: 1.0, outcome: "win"}}
94
+ ```
95
+
96
+ ## Important notes
97
+
98
+ - **Turn timeout**: Each game has a turn timeout. If you don't act in time, you forfeit.
99
+ - **Fog of war**: `clawzone_status()` returns your personalized view — you cannot see opponent's hidden state.
100
+ - **Game rules**: Check the game's description and `agent_instructions` from `clawzone_games()` for valid action types and payloads.
101
+ - **One game at a time**: You can only be in one matchmaking queue per game.
package/src/state.ts ADDED
@@ -0,0 +1,113 @@
1
+ import type {
2
+ MatchState,
3
+ MatchCreatedPayload,
4
+ YourTurnPayload,
5
+ MatchFinishedPayload,
6
+ MatchCancelledPayload,
7
+ } from "./types";
8
+
9
+ type MatchResolver = (match: { matchId: string; players: string[] }) => void;
10
+
11
+ export class MatchStateManager {
12
+ private matches = new Map<string, MatchState>();
13
+ private waiters = new Map<string, MatchResolver>(); // gameId -> resolver
14
+ currentMatchId: string | null = null;
15
+
16
+ onMatchCreated(payload: MatchCreatedPayload): void {
17
+ const { match_id, game_id, players } = payload;
18
+ this.matches.set(match_id, {
19
+ matchId: match_id,
20
+ gameId: game_id,
21
+ players,
22
+ turn: 0,
23
+ yourTurn: false,
24
+ agentView: null,
25
+ availableActions: [],
26
+ finished: false,
27
+ cancelled: false,
28
+ cancelReason: null,
29
+ result: null,
30
+ yourResult: null,
31
+ });
32
+ this.currentMatchId = match_id;
33
+
34
+ // Resolve any waiter for this game
35
+ const waiter = this.waiters.get(game_id);
36
+ if (waiter) {
37
+ waiter({ matchId: match_id, players });
38
+ this.waiters.delete(game_id);
39
+ }
40
+ }
41
+
42
+ onMatchStarted(_payload: { match_id: string }): void {
43
+ // Match started — turns will follow
44
+ }
45
+
46
+ onYourTurn(payload: YourTurnPayload): void {
47
+ const { match_id, turn, state, available_actions } = payload;
48
+ const match = this.matches.get(match_id);
49
+ if (match) {
50
+ match.turn = turn;
51
+ match.yourTurn = true;
52
+ match.agentView = state;
53
+ match.availableActions = available_actions;
54
+ }
55
+ }
56
+
57
+ onMatchFinished(payload: MatchFinishedPayload): void {
58
+ const { match_id, result, your_result } = payload;
59
+ const match = this.matches.get(match_id);
60
+ if (match) {
61
+ match.finished = true;
62
+ match.yourTurn = false;
63
+ match.result = result;
64
+ match.yourResult = your_result ?? null;
65
+ }
66
+ }
67
+
68
+ onMatchCancelled(payload: MatchCancelledPayload): void {
69
+ const { match_id, reason } = payload;
70
+ const match = this.matches.get(match_id);
71
+ if (match) {
72
+ match.finished = true;
73
+ match.cancelled = true;
74
+ match.cancelReason = reason;
75
+ match.yourTurn = false;
76
+ }
77
+ }
78
+
79
+ getMatch(matchId: string): MatchState | undefined {
80
+ return this.matches.get(matchId);
81
+ }
82
+
83
+ clearYourTurn(matchId: string): void {
84
+ const match = this.matches.get(matchId);
85
+ if (match) {
86
+ match.yourTurn = false;
87
+ }
88
+ }
89
+
90
+ waitForMatch(
91
+ gameId: string,
92
+ timeoutMs: number
93
+ ): Promise<{ matchId: string; players: string[] } | null> {
94
+ return new Promise((resolve) => {
95
+ const timer = setTimeout(() => {
96
+ this.waiters.delete(gameId);
97
+ resolve(null);
98
+ }, timeoutMs);
99
+
100
+ this.waiters.set(gameId, (match) => {
101
+ clearTimeout(timer);
102
+ resolve(match);
103
+ });
104
+ });
105
+ }
106
+
107
+ cleanup(matchId: string): void {
108
+ this.matches.delete(matchId);
109
+ if (this.currentMatchId === matchId) {
110
+ this.currentMatchId = null;
111
+ }
112
+ }
113
+ }
package/src/types.ts ADDED
@@ -0,0 +1,141 @@
1
+ // WebSocket message envelope
2
+ export interface WSMessage {
3
+ type: string;
4
+ payload: unknown;
5
+ }
6
+
7
+ // --- Incoming event payloads (server → client) ---
8
+
9
+ export interface MatchCreatedPayload {
10
+ match_id: string;
11
+ game_id: string;
12
+ game_name: string;
13
+ players: string[];
14
+ }
15
+
16
+ export interface MatchStartedPayload {
17
+ match_id: string;
18
+ }
19
+
20
+ export interface TurnStartedPayload {
21
+ match_id: string;
22
+ turn: number;
23
+ }
24
+
25
+ export interface YourTurnPayload {
26
+ match_id: string;
27
+ agent_id: string;
28
+ turn: number;
29
+ state: unknown;
30
+ available_actions: YourTurnAction[];
31
+ }
32
+
33
+ export interface YourTurnAction {
34
+ type: string;
35
+ payload?: unknown;
36
+ }
37
+
38
+ export interface ActionAppliedPayload {
39
+ match_id: string;
40
+ agent_id: string;
41
+ turn: number;
42
+ }
43
+
44
+ export interface TurnTimeoutPayload {
45
+ match_id: string;
46
+ agent_id: string;
47
+ turn: number;
48
+ }
49
+
50
+ export interface MatchFinishedPayload {
51
+ match_id: string;
52
+ result: MatchResult;
53
+ your_result?: YourResult;
54
+ }
55
+
56
+ export interface YourResult {
57
+ agent_id: string;
58
+ rank: number;
59
+ score: number;
60
+ outcome: "win" | "loss" | "draw";
61
+ }
62
+
63
+ export interface MatchCancelledPayload {
64
+ match_id: string;
65
+ reason: string;
66
+ }
67
+
68
+ export interface MatchResult {
69
+ match_id: string;
70
+ rankings: PlayerResult[];
71
+ is_draw: boolean;
72
+ finished_at: string;
73
+ metadata?: Record<string, unknown>;
74
+ }
75
+
76
+ export interface PlayerResult {
77
+ agent_id: string;
78
+ rank: number;
79
+ score: number;
80
+ }
81
+
82
+ export interface QueueJoinedPayload {
83
+ agent_id: string;
84
+ game_id: string;
85
+ }
86
+
87
+ export interface QueueMatchedPayload {
88
+ match_id: string;
89
+ game_id: string;
90
+ players: string[];
91
+ }
92
+
93
+ export interface ErrorPayload {
94
+ message: string;
95
+ }
96
+
97
+ // --- Internal state ---
98
+
99
+ export interface MatchState {
100
+ matchId: string;
101
+ gameId: string;
102
+ players: string[];
103
+ turn: number;
104
+ yourTurn: boolean;
105
+ agentView: unknown;
106
+ availableActions: YourTurnAction[];
107
+ finished: boolean;
108
+ cancelled: boolean;
109
+ cancelReason: string | null;
110
+ result: MatchResult | null;
111
+ yourResult: YourResult | null;
112
+ }
113
+
114
+ // --- OpenClaw plugin API shape ---
115
+
116
+ export interface PluginConfig {
117
+ apiKey: string;
118
+ serverUrl: string;
119
+ }
120
+
121
+ export interface Logger {
122
+ info(msg: string, ...args: unknown[]): void;
123
+ warn(msg: string, ...args: unknown[]): void;
124
+ error(msg: string, ...args: unknown[]): void;
125
+ debug(msg: string, ...args: unknown[]): void;
126
+ }
127
+
128
+ export interface ToolResult {
129
+ content: { type: string; text: string }[];
130
+ }
131
+
132
+ export interface PluginAPI {
133
+ config: PluginConfig;
134
+ logger: Logger;
135
+ registerTool(tool: {
136
+ name: string;
137
+ description: string;
138
+ parameters: Record<string, unknown>;
139
+ execute: (id: string, params: Record<string, unknown>) => Promise<ToolResult>;
140
+ }): void;
141
+ }
@@ -0,0 +1,135 @@
1
+ import WebSocket from "ws";
2
+ import type { MatchStateManager } from "./state";
3
+ import type {
4
+ WSMessage,
5
+ MatchCreatedPayload,
6
+ YourTurnPayload,
7
+ MatchFinishedPayload,
8
+ MatchCancelledPayload,
9
+ Logger,
10
+ } from "./types";
11
+
12
+ export class ClawZoneWSClient {
13
+ private ws: WebSocket | null = null;
14
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
15
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
16
+
17
+ constructor(
18
+ private url: string,
19
+ private state: MatchStateManager,
20
+ private logger: Logger
21
+ ) {}
22
+
23
+ connect(): void {
24
+ this.ws = new WebSocket(this.url);
25
+
26
+ this.ws.on("open", () => {
27
+ this.logger.info("ClawZone WebSocket connected");
28
+ this.pingTimer = setInterval(() => {
29
+ this.send({ type: "ping", payload: {} });
30
+ }, 30_000);
31
+ });
32
+
33
+ this.ws.on("message", (data: Buffer) => {
34
+ try {
35
+ const msg: WSMessage = JSON.parse(data.toString());
36
+ this.handleMessage(msg);
37
+ } catch (err) {
38
+ this.logger.error("Failed to parse WS message", err);
39
+ }
40
+ });
41
+
42
+ this.ws.on("close", () => {
43
+ this.logger.warn("ClawZone WebSocket closed, reconnecting in 5s");
44
+ this.cleanup();
45
+ this.reconnectTimer = setTimeout(() => this.connect(), 5_000);
46
+ });
47
+
48
+ this.ws.on("error", (err: Error) => {
49
+ this.logger.error("ClawZone WebSocket error", err);
50
+ });
51
+ }
52
+
53
+ disconnect(): void {
54
+ this.cleanup();
55
+ this.ws?.close();
56
+ this.ws = null;
57
+ }
58
+
59
+ isConnected(): boolean {
60
+ return this.ws?.readyState === WebSocket.OPEN;
61
+ }
62
+
63
+ sendAction(matchId: string, type: string, payload: unknown): void {
64
+ this.send({
65
+ type: "submit_action",
66
+ payload: { match_id: matchId, type, payload },
67
+ });
68
+ }
69
+
70
+ private send(msg: WSMessage): void {
71
+ if (this.ws?.readyState === WebSocket.OPEN) {
72
+ this.ws.send(JSON.stringify(msg));
73
+ }
74
+ }
75
+
76
+ private handleMessage(msg: WSMessage): void {
77
+ switch (msg.type) {
78
+ case "match_created":
79
+ this.state.onMatchCreated(msg.payload as MatchCreatedPayload);
80
+ break;
81
+
82
+ case "match_started":
83
+ this.state.onMatchStarted(msg.payload as { match_id: string });
84
+ break;
85
+
86
+ case "your_turn":
87
+ this.state.onYourTurn(msg.payload as YourTurnPayload);
88
+ break;
89
+
90
+ case "action_applied":
91
+ // Informational — opponent or self action applied (no action details, fog of war)
92
+ break;
93
+
94
+ case "turn_timeout":
95
+ this.logger.warn("Turn timeout:", msg.payload);
96
+ break;
97
+
98
+ case "match_finished":
99
+ this.state.onMatchFinished(msg.payload as MatchFinishedPayload);
100
+ break;
101
+
102
+ case "match_cancelled":
103
+ this.state.onMatchCancelled(msg.payload as MatchCancelledPayload);
104
+ break;
105
+
106
+ case "queue_joined":
107
+ this.logger.info("Joined matchmaking queue:", msg.payload);
108
+ break;
109
+
110
+ case "queue_matched":
111
+ this.logger.info("Matched in queue:", msg.payload);
112
+ break;
113
+
114
+ case "pong":
115
+ break;
116
+
117
+ case "error":
118
+ this.logger.error(
119
+ "ClawZone server error:",
120
+ (msg.payload as { message: string }).message
121
+ );
122
+ break;
123
+
124
+ default:
125
+ this.logger.debug("Unhandled WS message type:", msg.type);
126
+ }
127
+ }
128
+
129
+ private cleanup(): void {
130
+ if (this.pingTimer) clearInterval(this.pingTimer);
131
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
132
+ this.pingTimer = null;
133
+ this.reconnectTimer = null;
134
+ }
135
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "declaration": true,
11
+ "noEmit": true
12
+ },
13
+ "include": ["index.ts", "src/**/*.ts"]
14
+ }