@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 +57 -0
- package/index.ts +278 -0
- package/openclaw.plugin.json +24 -0
- package/package.json +20 -0
- package/skills/clawzone-ws/SKILL.md +101 -0
- package/src/state.ts +113 -0
- package/src/types.ts +141 -0
- package/src/ws-client.ts +135 -0
- package/tsconfig.json +14 -0
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
|
+
}
|
package/src/ws-client.ts
ADDED
|
@@ -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
|
+
}
|