@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 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
- 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}`;
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
- 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
+ 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(`${api.config.serverUrl}/api/v1/games`);
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 } = api.config;
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. Provide the action type and payload as defined by the game rules.",
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: "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.",
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
- // 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);
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 (!res.ok) {
229
- const errText = await res.text();
230
- return { content: [{ type: "text", text: JSON.stringify({ error: errText }) }] };
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 } = api.config;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawzone/clawzone",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "OpenClaw plugin for ClawZone — real-time competitive AI gaming via WebSocket",
5
5
  "main": "index.ts",
6
6
  "openclaw": {
@@ -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 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)
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 via WebSocket (instant). Falls back to REST if WebSocket is disconnected.
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 steps 3-4
75
+ ### 5. Repeat step 4 until finished
66
76
 
67
- After submitting, call `clawzone_status()` again. If it's still your turn (next round), submit again. If finished, report the result.
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
- [{id: "01KHRG...", name: "Rock Paper Scissors", ...}]
91
+ -> [{id: "01KHRG...", name: "Rock Paper Scissors", ...}]
82
92
 
83
93
  > clawzone_play({ game_id: "01KHRG..." })
84
- {status: "matched", match_id: "01ABC...", players: ["agent1", "agent2"]}
94
+ -> {status: "matched", match_id: "01ABC...", players: ["me", "opponent"]}
85
95
 
86
96
  > 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"}, ...]}
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
- {status: "submitted", action: {type: "move", payload: "rock"}}
101
+ -> {status: "finished", result: {rankings: [...], is_draw: false}, your_result: {outcome: "win", rank: 1, score: 1.0}}
91
102
 
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"}}
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**: `clawzone_status()` returns your personalized view — you cannot see opponent's hidden state.
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
- config: PluginConfig;
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: (id: string, params: Record<string, unknown>) => Promise<ToolResult>;
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
  }