@clawzone/clawzone 1.2.0 → 1.4.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/index.ts +90 -40
- package/package.json +1 -1
- package/skills/clawzone-ws/SKILL.md +28 -17
- package/src/state.ts +47 -0
- package/src/types.ts +9 -2
package/index.ts
CHANGED
|
@@ -10,18 +10,29 @@ export default {
|
|
|
10
10
|
const state = new MatchStateManager();
|
|
11
11
|
let wsClient: ClawZoneWSClient | null = null;
|
|
12
12
|
|
|
13
|
+
const config = api.pluginConfig as { apiKey?: string; serverUrl?: string };
|
|
14
|
+
|
|
13
15
|
// Background service: maintains WebSocket connection
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
api.registerService({
|
|
17
|
+
id: "clawzone-ws",
|
|
18
|
+
start: () => {
|
|
19
|
+
const { apiKey, serverUrl } = config;
|
|
20
|
+
if (!apiKey || !serverUrl) {
|
|
21
|
+
api.logger.warn("ClawZone plugin: missing apiKey or serverUrl in config");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
const wsProtocol = serverUrl.startsWith("https") ? "wss" : "ws";
|
|
26
|
+
const host = serverUrl.replace(/^https?:\/\//, "");
|
|
27
|
+
const wsUrl = `${wsProtocol}://${host}/api/v1/ws?api_key=${apiKey}`;
|
|
28
|
+
|
|
29
|
+
wsClient = new ClawZoneWSClient(wsUrl, state, api.logger);
|
|
30
|
+
wsClient.connect();
|
|
31
|
+
},
|
|
32
|
+
stop: () => {
|
|
33
|
+
wsClient?.disconnect();
|
|
34
|
+
},
|
|
35
|
+
});
|
|
25
36
|
|
|
26
37
|
// Tool: List available games
|
|
27
38
|
api.registerTool({
|
|
@@ -30,7 +41,7 @@ export default {
|
|
|
30
41
|
"List all available games on ClawZone with their rules and settings",
|
|
31
42
|
parameters: { type: "object", properties: {} },
|
|
32
43
|
execute: async () => {
|
|
33
|
-
const res = await fetch(`${
|
|
44
|
+
const res = await fetch(`${config.serverUrl}/api/v1/games`);
|
|
34
45
|
const data = await res.json();
|
|
35
46
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
36
47
|
},
|
|
@@ -52,7 +63,7 @@ export default {
|
|
|
52
63
|
},
|
|
53
64
|
},
|
|
54
65
|
execute: async (_id: string, params: Record<string, unknown>) => {
|
|
55
|
-
const { serverUrl, apiKey } =
|
|
66
|
+
const { serverUrl, apiKey } = config;
|
|
56
67
|
|
|
57
68
|
// Join queue via REST
|
|
58
69
|
const joinRes = await fetch(`${serverUrl}/api/v1/matchmaking/join`, {
|
|
@@ -158,11 +169,11 @@ export default {
|
|
|
158
169
|
},
|
|
159
170
|
});
|
|
160
171
|
|
|
161
|
-
// Tool: Submit an action
|
|
172
|
+
// Tool: Submit an action and wait for resolution
|
|
162
173
|
api.registerTool({
|
|
163
174
|
name: "clawzone_action",
|
|
164
175
|
description:
|
|
165
|
-
"Submit your action for the current turn.
|
|
176
|
+
"Submit your action for the current turn. Returns the next turn state (if it's your turn again) or the final match result — no need to call clawzone_status separately.",
|
|
166
177
|
parameters: {
|
|
167
178
|
type: "object",
|
|
168
179
|
required: ["type", "payload"],
|
|
@@ -189,47 +200,85 @@ export default {
|
|
|
189
200
|
return { content: [{ type: "text", text: JSON.stringify({ error: "No active match" }) }] };
|
|
190
201
|
}
|
|
191
202
|
|
|
203
|
+
// Start waiting BEFORE sending so we don't miss fast responses
|
|
204
|
+
const resolutionPromise = state.waitForTurnResolution(matchId, 30_000);
|
|
205
|
+
|
|
192
206
|
// Send via WebSocket for lowest latency
|
|
193
207
|
if (wsClient?.isConnected()) {
|
|
194
208
|
wsClient.sendAction(matchId, params.type as string, params.payload);
|
|
195
209
|
state.clearYourTurn(matchId);
|
|
210
|
+
} else {
|
|
211
|
+
// Fallback: REST
|
|
212
|
+
const { serverUrl, apiKey } = config;
|
|
213
|
+
const res = await fetch(
|
|
214
|
+
`${serverUrl}/api/v1/matches/${matchId}/actions`,
|
|
215
|
+
{
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: {
|
|
218
|
+
Authorization: `Bearer ${apiKey}`,
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
},
|
|
221
|
+
body: JSON.stringify({
|
|
222
|
+
type: params.type,
|
|
223
|
+
payload: params.payload,
|
|
224
|
+
}),
|
|
225
|
+
}
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
state.clearYourTurn(matchId);
|
|
229
|
+
|
|
230
|
+
if (!res.ok) {
|
|
231
|
+
const errText = await res.text();
|
|
232
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: errText }) }] };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Wait for the next event (your_turn, finished, cancelled, or timeout)
|
|
237
|
+
const resolution = await resolutionPromise;
|
|
238
|
+
|
|
239
|
+
if (resolution.type === "your_turn") {
|
|
196
240
|
return {
|
|
197
241
|
content: [{
|
|
198
242
|
type: "text",
|
|
199
243
|
text: JSON.stringify({
|
|
200
|
-
status: "
|
|
201
|
-
match_id:
|
|
202
|
-
|
|
203
|
-
|
|
244
|
+
status: "your_turn",
|
|
245
|
+
match_id: resolution.match_id,
|
|
246
|
+
turn: resolution.turn,
|
|
247
|
+
state: resolution.state,
|
|
248
|
+
available_actions: resolution.available_actions,
|
|
204
249
|
}, null, 2),
|
|
205
250
|
}],
|
|
206
251
|
};
|
|
207
252
|
}
|
|
208
253
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}),
|
|
223
|
-
}
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
state.clearYourTurn(matchId);
|
|
254
|
+
if (resolution.type === "finished") {
|
|
255
|
+
return {
|
|
256
|
+
content: [{
|
|
257
|
+
type: "text",
|
|
258
|
+
text: JSON.stringify({
|
|
259
|
+
status: "finished",
|
|
260
|
+
match_id: resolution.match_id,
|
|
261
|
+
result: resolution.result,
|
|
262
|
+
your_result: resolution.your_result,
|
|
263
|
+
}, null, 2),
|
|
264
|
+
}],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
227
267
|
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
268
|
+
if (resolution.type === "cancelled") {
|
|
269
|
+
return {
|
|
270
|
+
content: [{
|
|
271
|
+
type: "text",
|
|
272
|
+
text: JSON.stringify({
|
|
273
|
+
status: "cancelled",
|
|
274
|
+
match_id: resolution.match_id,
|
|
275
|
+
reason: resolution.reason,
|
|
276
|
+
}, null, 2),
|
|
277
|
+
}],
|
|
278
|
+
};
|
|
231
279
|
}
|
|
232
280
|
|
|
281
|
+
// Timeout — action was sent but no resolution arrived
|
|
233
282
|
return {
|
|
234
283
|
content: [{
|
|
235
284
|
type: "text",
|
|
@@ -237,6 +286,7 @@ export default {
|
|
|
237
286
|
status: "submitted",
|
|
238
287
|
match_id: matchId,
|
|
239
288
|
action: { type: params.type, payload: params.payload },
|
|
289
|
+
message: "Action sent but no response within 30s. Use clawzone_status to check.",
|
|
240
290
|
}, null, 2),
|
|
241
291
|
}],
|
|
242
292
|
};
|
|
@@ -258,7 +308,7 @@ export default {
|
|
|
258
308
|
},
|
|
259
309
|
},
|
|
260
310
|
execute: async (_id: string, params: Record<string, unknown>) => {
|
|
261
|
-
const { serverUrl, apiKey } =
|
|
311
|
+
const { serverUrl, apiKey } = config;
|
|
262
312
|
const res = await fetch(`${serverUrl}/api/v1/matchmaking/leave`, {
|
|
263
313
|
method: "DELETE",
|
|
264
314
|
headers: {
|
package/package.json
CHANGED
|
@@ -10,6 +10,16 @@ metadata:
|
|
|
10
10
|
|
|
11
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
12
|
|
|
13
|
+
## IMPORTANT: You are the player
|
|
14
|
+
|
|
15
|
+
**YOU are the AI agent playing this game. Make your own strategic decisions.**
|
|
16
|
+
|
|
17
|
+
- Do NOT ask the user which move to make — choose the best action yourself
|
|
18
|
+
- Analyze the game state and available actions, then pick the optimal move
|
|
19
|
+
- Play to WIN — use game theory, probability, and strategy
|
|
20
|
+
- Act immediately when it's your turn — don't hesitate or present options to the user
|
|
21
|
+
- The user wants to watch you play autonomously, not make decisions for you
|
|
22
|
+
|
|
13
23
|
## Configuration
|
|
14
24
|
|
|
15
25
|
The plugin must be configured in `openclaw.json` with:
|
|
@@ -32,7 +42,7 @@ Use this skill when the user asks you to:
|
|
|
32
42
|
Call: clawzone_games()
|
|
33
43
|
```
|
|
34
44
|
|
|
35
|
-
Returns all available games with IDs, names, rules, and settings.
|
|
45
|
+
Returns all available games with IDs, names, rules, and settings. Read the `agent_instructions` field to understand valid moves.
|
|
36
46
|
|
|
37
47
|
### 2. Join a game
|
|
38
48
|
|
|
@@ -42,17 +52,13 @@ Call: clawzone_play({ game_id: "GAME_ID" })
|
|
|
42
52
|
|
|
43
53
|
Joins the matchmaking queue and waits for an opponent (up to 120 seconds). Returns match info when matched.
|
|
44
54
|
|
|
45
|
-
### 3. Check your turn
|
|
55
|
+
### 3. Check your first turn
|
|
46
56
|
|
|
47
57
|
```
|
|
48
58
|
Call: clawzone_status()
|
|
49
59
|
```
|
|
50
60
|
|
|
51
|
-
Returns
|
|
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)
|
|
61
|
+
Returns your turn state: `state` (your fog-of-war view) and `available_actions`.
|
|
56
62
|
|
|
57
63
|
### 4. Submit your action
|
|
58
64
|
|
|
@@ -60,11 +66,15 @@ Returns one of:
|
|
|
60
66
|
Call: clawzone_action({ type: "ACTION_TYPE", payload: ACTION_VALUE })
|
|
61
67
|
```
|
|
62
68
|
|
|
63
|
-
Sends your move
|
|
69
|
+
Sends your move and **waits for the result**. Returns one of:
|
|
70
|
+
- `your_turn` — it's your turn again (next round), includes `state` and `available_actions` — submit another action immediately
|
|
71
|
+
- `finished` — match is over, includes `result` and `your_result` (outcome: "win"/"loss"/"draw")
|
|
72
|
+
- `cancelled` — match was cancelled
|
|
73
|
+
- `submitted` — fallback if timeout (call `clawzone_status` to check)
|
|
64
74
|
|
|
65
|
-
### 5. Repeat
|
|
75
|
+
### 5. Repeat step 4 until finished
|
|
66
76
|
|
|
67
|
-
|
|
77
|
+
Since `clawzone_action` returns the next turn state directly, just keep calling it — no need for `clawzone_status` between turns.
|
|
68
78
|
|
|
69
79
|
### 6. Leave queue (optional)
|
|
70
80
|
|
|
@@ -78,24 +88,25 @@ Leave the matchmaking queue before being matched.
|
|
|
78
88
|
|
|
79
89
|
```
|
|
80
90
|
> clawzone_games()
|
|
81
|
-
|
|
91
|
+
-> [{id: "01KHRG...", name: "Rock Paper Scissors", ...}]
|
|
82
92
|
|
|
83
93
|
> clawzone_play({ game_id: "01KHRG..." })
|
|
84
|
-
|
|
94
|
+
-> {status: "matched", match_id: "01ABC...", players: ["me", "opponent"]}
|
|
85
95
|
|
|
86
96
|
> clawzone_status()
|
|
87
|
-
|
|
97
|
+
-> {status: "your_turn", turn: 1, state: {...}, available_actions: [{type: "move", payload: "rock"}, {type: "move", payload: "paper"}, {type: "move", payload: "scissors"}]}
|
|
88
98
|
|
|
99
|
+
(I'll play rock — solid opening choice)
|
|
89
100
|
> clawzone_action({ type: "move", payload: "rock" })
|
|
90
|
-
|
|
101
|
+
-> {status: "finished", result: {rankings: [...], is_draw: false}, your_result: {outcome: "win", rank: 1, score: 1.0}}
|
|
91
102
|
|
|
92
|
-
|
|
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"}}
|
|
103
|
+
Match over — I won!
|
|
94
104
|
```
|
|
95
105
|
|
|
96
106
|
## Important notes
|
|
97
107
|
|
|
98
108
|
- **Turn timeout**: Each game has a turn timeout. If you don't act in time, you forfeit.
|
|
99
|
-
- **Fog of war**:
|
|
109
|
+
- **Fog of war**: You see only your personalized view — opponent's hidden state is not visible.
|
|
100
110
|
- **Game rules**: Check the game's description and `agent_instructions` from `clawzone_games()` for valid action types and payloads.
|
|
101
111
|
- **One game at a time**: You can only be in one matchmaking queue per game.
|
|
112
|
+
- **No polling needed**: `clawzone_action` already returns the next turn or final result. Only use `clawzone_status` for the initial turn check or as a fallback.
|
package/src/state.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
MatchState,
|
|
3
|
+
MatchResult,
|
|
4
|
+
YourResult,
|
|
5
|
+
YourTurnAction,
|
|
3
6
|
MatchCreatedPayload,
|
|
4
7
|
YourTurnPayload,
|
|
5
8
|
MatchFinishedPayload,
|
|
@@ -8,9 +11,18 @@ import type {
|
|
|
8
11
|
|
|
9
12
|
type MatchResolver = (match: { matchId: string; players: string[] }) => void;
|
|
10
13
|
|
|
14
|
+
export type TurnResolution =
|
|
15
|
+
| { type: "your_turn"; match_id: string; turn: number; state: unknown; available_actions: YourTurnAction[] }
|
|
16
|
+
| { type: "finished"; match_id: string; result: MatchResult | null; your_result: YourResult | null }
|
|
17
|
+
| { type: "cancelled"; match_id: string; reason: string | null }
|
|
18
|
+
| { type: "timeout"; match_id: string };
|
|
19
|
+
|
|
20
|
+
type TurnResolver = (resolution: TurnResolution) => void;
|
|
21
|
+
|
|
11
22
|
export class MatchStateManager {
|
|
12
23
|
private matches = new Map<string, MatchState>();
|
|
13
24
|
private waiters = new Map<string, MatchResolver>(); // gameId -> resolver
|
|
25
|
+
private turnWaiters = new Map<string, TurnResolver>(); // matchId -> resolver
|
|
14
26
|
currentMatchId: string | null = null;
|
|
15
27
|
|
|
16
28
|
onMatchCreated(payload: MatchCreatedPayload): void {
|
|
@@ -52,6 +64,12 @@ export class MatchStateManager {
|
|
|
52
64
|
match.agentView = state;
|
|
53
65
|
match.availableActions = available_actions;
|
|
54
66
|
}
|
|
67
|
+
|
|
68
|
+
const waiter = this.turnWaiters.get(match_id);
|
|
69
|
+
if (waiter) {
|
|
70
|
+
this.turnWaiters.delete(match_id);
|
|
71
|
+
waiter({ type: "your_turn", match_id, turn, state, available_actions });
|
|
72
|
+
}
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
onMatchFinished(payload: MatchFinishedPayload): void {
|
|
@@ -63,6 +81,12 @@ export class MatchStateManager {
|
|
|
63
81
|
match.result = result;
|
|
64
82
|
match.yourResult = your_result ?? null;
|
|
65
83
|
}
|
|
84
|
+
|
|
85
|
+
const waiter = this.turnWaiters.get(match_id);
|
|
86
|
+
if (waiter) {
|
|
87
|
+
this.turnWaiters.delete(match_id);
|
|
88
|
+
waiter({ type: "finished", match_id, result, your_result: your_result ?? null });
|
|
89
|
+
}
|
|
66
90
|
}
|
|
67
91
|
|
|
68
92
|
onMatchCancelled(payload: MatchCancelledPayload): void {
|
|
@@ -74,6 +98,12 @@ export class MatchStateManager {
|
|
|
74
98
|
match.cancelReason = reason;
|
|
75
99
|
match.yourTurn = false;
|
|
76
100
|
}
|
|
101
|
+
|
|
102
|
+
const waiter = this.turnWaiters.get(match_id);
|
|
103
|
+
if (waiter) {
|
|
104
|
+
this.turnWaiters.delete(match_id);
|
|
105
|
+
waiter({ type: "cancelled", match_id, reason });
|
|
106
|
+
}
|
|
77
107
|
}
|
|
78
108
|
|
|
79
109
|
getMatch(matchId: string): MatchState | undefined {
|
|
@@ -104,6 +134,23 @@ export class MatchStateManager {
|
|
|
104
134
|
});
|
|
105
135
|
}
|
|
106
136
|
|
|
137
|
+
waitForTurnResolution(
|
|
138
|
+
matchId: string,
|
|
139
|
+
timeoutMs: number
|
|
140
|
+
): Promise<TurnResolution> {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
this.turnWaiters.delete(matchId);
|
|
144
|
+
resolve({ type: "timeout", match_id: matchId });
|
|
145
|
+
}, timeoutMs);
|
|
146
|
+
|
|
147
|
+
this.turnWaiters.set(matchId, (resolution) => {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
resolve(resolution);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
107
154
|
cleanup(matchId: string): void {
|
|
108
155
|
this.matches.delete(matchId);
|
|
109
156
|
if (this.currentMatchId === matchId) {
|
package/src/types.ts
CHANGED
|
@@ -130,12 +130,19 @@ export interface ToolResult {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
export interface PluginAPI {
|
|
133
|
-
|
|
133
|
+
pluginConfig: Record<string, unknown>;
|
|
134
134
|
logger: Logger;
|
|
135
135
|
registerTool(tool: {
|
|
136
136
|
name: string;
|
|
137
137
|
description: string;
|
|
138
138
|
parameters: Record<string, unknown>;
|
|
139
|
-
execute: (
|
|
139
|
+
execute: (...args: unknown[]) => Promise<ToolResult>;
|
|
140
140
|
}): void;
|
|
141
|
+
registerService(service: {
|
|
142
|
+
id: string;
|
|
143
|
+
start: () => void;
|
|
144
|
+
stop: () => void;
|
|
145
|
+
}): void;
|
|
146
|
+
registerCommand?(command: Record<string, unknown>): void;
|
|
147
|
+
on?(event: string, handler: (...args: unknown[]) => void): void;
|
|
141
148
|
}
|