@agentmessier/openclaw-agent-messier 0.3.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 +70 -0
- package/index.ts +121 -0
- package/openclaw.plugin.json +110 -0
- package/package.json +36 -0
- package/src/format.ts +173 -0
- package/src/spec.test.ts +106 -0
- package/src/state.ts +23 -0
- package/src/tools.test.ts +55 -0
- package/src/tools.ts +757 -0
- package/src/venues.test.ts +67 -0
- package/src/watcher.test.ts +121 -0
- package/src/watcher.ts +216 -0
package/src/tools.ts
ADDED
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join as joinPath } from "node:path";
|
|
5
|
+
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
6
|
+
import { describeTeam, type TeamView } from "./format.js";
|
|
7
|
+
import { session } from "./state.js";
|
|
8
|
+
|
|
9
|
+
export type PluginCfg = {
|
|
10
|
+
serverUrl?: string;
|
|
11
|
+
/** Auto quick-match at startup: find a waiting room (teamSize) or create one.
|
|
12
|
+
* The standard zero-touch way to get a demo/bot team playing. */
|
|
13
|
+
autoJoin?: boolean;
|
|
14
|
+
/** Preferred players-per-side for quick-match / created rooms (default 5). */
|
|
15
|
+
teamSize?: number;
|
|
16
|
+
/** Optional: pin a specific room at startup (overrides autoJoin). */
|
|
17
|
+
matchId?: string;
|
|
18
|
+
/** Optional side preference. */
|
|
19
|
+
team?: "home" | "away";
|
|
20
|
+
sessionKey?: string;
|
|
21
|
+
/** AgentNet API key — sent as Bearer on join so the server can verify identity
|
|
22
|
+
* (REQUIRE_AUTH). Falls back to the AGENTNET_API_KEY env var. */
|
|
23
|
+
apiKey?: string;
|
|
24
|
+
/** AgentNet accounts service (person plane) — used to redeem owner claim codes. */
|
|
25
|
+
accountsUrl?: string;
|
|
26
|
+
mode?: "easy" | "advanced" | "both";
|
|
27
|
+
/** Default team identity (changeable at runtime via soccer_set_identity). */
|
|
28
|
+
teamName?: string;
|
|
29
|
+
nation?: string; // ISO code like NL/IT/CN → flag shown in the viewer
|
|
30
|
+
clan?: string; // e.g. 魔兽工会-style guild tag
|
|
31
|
+
style?: string; // playing identity, e.g. 全攻全守 total football — shapes play
|
|
32
|
+
/** Path to a human-editable strategy.md injected into the watcher move prompt
|
|
33
|
+
* (Phase 5). Capped ~1k chars, mtime-cached so edits apply mid-match. */
|
|
34
|
+
strategyFile?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function identityOf(cfg: PluginCfg): Record<string, unknown> {
|
|
38
|
+
return { name: cfg.teamName, nation: cfg.nation, clan: cfg.clan, style: cfg.style };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── /spec manifest (Phase 4 — generate tools from the published action schema) ──
|
|
42
|
+
/** The slice of GET /spec this plugin consumes: the published action vocabulary. */
|
|
43
|
+
export type GameSpec = {
|
|
44
|
+
game: string;
|
|
45
|
+
specVersion: number;
|
|
46
|
+
rulesVersion: number;
|
|
47
|
+
actions: { type: "string"; enum: string[]; descriptions?: Record<string, string> };
|
|
48
|
+
/** The self-instructable envelope — how to play, server-authored, frozen per
|
|
49
|
+
* match. Optional: absent on pre-envelope servers (fallback prompt used). */
|
|
50
|
+
instructions?: { system: string; play: string; output: string };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// STATIC FALLBACK vocabularies — used when /spec is unreachable (offline-safe).
|
|
54
|
+
// `ADVANCED_ACTIONS` is also the classifier that splits a manifest's enum into
|
|
55
|
+
// the easy tier (server computes geometry) vs the advanced tier (agent does).
|
|
56
|
+
const EASY_ACTIONS = ["chase", "shoot", "dribble", "pass", "defend", "press", "cover", "stop"];
|
|
57
|
+
const ADVANCED_ACTIONS = ["run", "kick", "stop"];
|
|
58
|
+
|
|
59
|
+
/** Fetch GET /spec (the game manifest). Returns null when unreachable or
|
|
60
|
+
* malformed so callers fall back to the static vocabulary (offline-safe). */
|
|
61
|
+
export async function fetchSpec(cfg: PluginCfg): Promise<GameSpec | null> {
|
|
62
|
+
const base = (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`${base}/spec`);
|
|
65
|
+
if (!res.ok) return null;
|
|
66
|
+
const spec = (await res.json()) as GameSpec;
|
|
67
|
+
if (!spec || typeof spec !== "object" || !Array.isArray(spec.actions?.enum)) return null;
|
|
68
|
+
return spec;
|
|
69
|
+
} catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Per-match spec snapshot with the protection ladder (self-instructable-
|
|
73
|
+
* observation.md): the match's frozen snapshot first, then server-current
|
|
74
|
+
* /spec (pre-snapshot servers), then null — the caller stays on its static
|
|
75
|
+
* fallback and retries on a later tick (degraded, never permanently). */
|
|
76
|
+
export async function fetchMatchSpec(cfg: PluginCfg, matchId: string): Promise<GameSpec | null> {
|
|
77
|
+
const base = (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
|
|
78
|
+
for (const url of [`${base}/matches/${encodeURIComponent(matchId)}/spec`, `${base}/spec`]) {
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(url);
|
|
81
|
+
if (!res.ok) continue;
|
|
82
|
+
const spec = (await res.json()) as GameSpec;
|
|
83
|
+
if (spec && typeof spec === "object" && Array.isArray(spec.actions?.enum)) return spec;
|
|
84
|
+
} catch { /* next rung */ }
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The action enum for the play tool at a given tier: derived from the manifest
|
|
90
|
+
* when present (easy = manifest minus the raw run/kick geometry actions; advanced
|
|
91
|
+
* = just those), else the static fallback. New server actions surface for free. */
|
|
92
|
+
export function playActionTypes(spec: GameSpec | null, mode: "easy" | "advanced" | "both"): string[] {
|
|
93
|
+
if (!spec) return mode === "easy" ? EASY_ACTIONS : mode === "advanced" ? ADVANCED_ACTIONS : [...new Set([...EASY_ACTIONS, ...ADVANCED_ACTIONS])];
|
|
94
|
+
const all = spec.actions.enum.filter((a) => typeof a === "string");
|
|
95
|
+
const advanced = all.filter((a) => a === "run" || a === "kick" || a === "stop");
|
|
96
|
+
const easy = all.filter((a) => a !== "run" && a !== "kick" && a !== "idle");
|
|
97
|
+
if (!easy.includes("stop")) easy.push("stop");
|
|
98
|
+
return mode === "easy" ? easy : mode === "advanced" ? advanced : [...new Set([...easy, ...advanced])];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Stable cross-process cache key for this agent (one gateway = one sessionKey).
|
|
102
|
+
* Used for the tmp-file caches — known before any join, unlike the DID. */
|
|
103
|
+
function idKey(cfg: PluginCfg): string {
|
|
104
|
+
return cfg.sessionKey ?? "agent";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** AgentNet API key for join-time verification (config, then env fallback). */
|
|
108
|
+
export function apiKeyOf(cfg: PluginCfg): string | undefined {
|
|
109
|
+
return cfg.apiKey ?? process.env.AGENTNET_API_KEY ?? undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The agentId the server seats us under. Once the server has verified us and
|
|
113
|
+
* returned a DID, that DID is our identity (it keys our seat + player calls);
|
|
114
|
+
* before that, the configured sessionKey (also used in dev / REQUIRE_AUTH=0). */
|
|
115
|
+
export function agentIdOf(cfg: PluginCfg): string {
|
|
116
|
+
return recallDid(cfg) ?? cfg.sessionKey ?? "agent";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Per-agent tmp caches shared across processes (the gateway joins; chat-turn
|
|
120
|
+
* processes need the same token + DID to act). Keyed on the stable idKey. */
|
|
121
|
+
function cachePath(kind: string, key: string): string {
|
|
122
|
+
return joinPath(tmpdir(), `agentnet-soccer-${kind}-${key.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`);
|
|
123
|
+
}
|
|
124
|
+
export function rememberToken(cfg: PluginCfg, token: string): void {
|
|
125
|
+
session.token = token;
|
|
126
|
+
try { writeFileSync(cachePath("token", idKey(cfg)), JSON.stringify({ token }), { mode: 0o600 }); } catch { /* best-effort */ }
|
|
127
|
+
}
|
|
128
|
+
export function recallToken(cfg: PluginCfg): string | null {
|
|
129
|
+
if (session.token) return session.token;
|
|
130
|
+
try {
|
|
131
|
+
const t = (JSON.parse(readFileSync(cachePath("token", idKey(cfg)), "utf8")) as { token?: string }).token;
|
|
132
|
+
if (typeof t === "string") { session.token = t; return t; }
|
|
133
|
+
} catch { /* none yet */ }
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
export function rememberDid(cfg: PluginCfg, did: string): void {
|
|
137
|
+
session.did = did;
|
|
138
|
+
try { writeFileSync(cachePath("did", idKey(cfg)), JSON.stringify({ did }), { mode: 0o600 }); } catch { /* best-effort */ }
|
|
139
|
+
}
|
|
140
|
+
export function recallDid(cfg: PluginCfg): string | null {
|
|
141
|
+
if (session.did) return session.did;
|
|
142
|
+
try {
|
|
143
|
+
const d = (JSON.parse(readFileSync(cachePath("did", idKey(cfg)), "utf8")) as { did?: string }).did;
|
|
144
|
+
if (typeof d === "string") { session.did = d; return d; }
|
|
145
|
+
} catch { /* none yet */ }
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function pitchClient(cfg: PluginCfg) {
|
|
150
|
+
const base = (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
|
|
151
|
+
const authHeaders = (): Record<string, string> => {
|
|
152
|
+
const t = recallToken(cfg);
|
|
153
|
+
return t ? { "x-agent-token": t } : {};
|
|
154
|
+
};
|
|
155
|
+
// Bearer API key — sent on join so the server can verify identity → DID.
|
|
156
|
+
const bearer = (): Record<string, string> => {
|
|
157
|
+
const k = apiKeyOf(cfg);
|
|
158
|
+
return k ? { Authorization: `Bearer ${k}` } : {};
|
|
159
|
+
};
|
|
160
|
+
// Tells the lobby roster what kind of client holds this seat.
|
|
161
|
+
const RUNTIME = { "x-agent-runtime": "openclaw-plugin/0.1.0" };
|
|
162
|
+
// The room is dynamic. In-process state is set when the watcher joins, but
|
|
163
|
+
// tools may run in a DIFFERENT process (CLI chat, dashboard) — so fall back
|
|
164
|
+
// to asking the server which room this agentId is seated in.
|
|
165
|
+
const m = async (): Promise<string> => {
|
|
166
|
+
const id = session.matchId ?? cfg.matchId;
|
|
167
|
+
if (id) return encodeURIComponent(id);
|
|
168
|
+
const res = await fetch(`${base}/matches`);
|
|
169
|
+
if (res.ok) {
|
|
170
|
+
const { matches } = (await res.json()) as { matches: { id: string; status: string; sides: { home: string | null; away: string | null } }[] };
|
|
171
|
+
const me = agentIdOf(cfg);
|
|
172
|
+
const seat = matches.find(r => r.status !== "ended" && (r.sides.home === me || r.sides.away === me));
|
|
173
|
+
if (seat) { session.matchId = seat.id; return encodeURIComponent(seat.id); }
|
|
174
|
+
}
|
|
175
|
+
throw new Error("not in a match — use soccer_join_match or soccer_create_match first");
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
async lobby(): Promise<{ matches: { id: string; status: string; teamSize: number; maxGoals: number; score: { home: number; away: number }; sides: { home: string | null; away: string | null } }[] }> {
|
|
179
|
+
const res = await fetch(`${base}/matches`);
|
|
180
|
+
if (!res.ok) throw new Error(`pitch lobby: ${res.status} ${await res.text()}`);
|
|
181
|
+
return res.json() as Promise<any>;
|
|
182
|
+
},
|
|
183
|
+
async createRoom(opts: { teamSize: number; maxGoals?: number }): Promise<{ id: string }> {
|
|
184
|
+
const res = await fetch(`${base}/matches`, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { "Content-Type": "application/json" },
|
|
187
|
+
body: JSON.stringify({ duel: true, teamSize: opts.teamSize, maxGoals: opts.maxGoals ?? 5 }),
|
|
188
|
+
});
|
|
189
|
+
if (!res.ok) throw new Error(`pitch create: ${res.status} ${await res.text()}`);
|
|
190
|
+
return res.json() as Promise<{ id: string }>;
|
|
191
|
+
},
|
|
192
|
+
async quickMatch(agentId: string, opts: { teamSize?: number; team?: "home" | "away" } = {}): Promise<{ matchId: string; team: "home" | "away"; playerIds: string[]; started: boolean }> {
|
|
193
|
+
const res = await fetch(`${base}/quickmatch`, {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: { "Content-Type": "application/json", ...RUNTIME, ...bearer() },
|
|
196
|
+
body: JSON.stringify({ agentId, teamSize: opts.teamSize ?? cfg.teamSize ?? 5, team: opts.team, identity: identityOf(cfg) }),
|
|
197
|
+
});
|
|
198
|
+
if (!res.ok) throw new Error(`pitch quickmatch: ${res.status} ${await res.text()}`);
|
|
199
|
+
const data = (await res.json()) as any;
|
|
200
|
+
if (typeof data.did === "string") rememberDid(cfg, data.did);
|
|
201
|
+
if (typeof data.token === "string") rememberToken(cfg, data.token);
|
|
202
|
+
return data;
|
|
203
|
+
},
|
|
204
|
+
async join(matchId: string, agentId: string, team?: "home" | "away"): Promise<{ team: "home" | "away"; playerIds: string[]; started: boolean }> {
|
|
205
|
+
const res = await fetch(`${base}/matches/${encodeURIComponent(matchId)}/join`, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "Content-Type": "application/json", ...RUNTIME, ...bearer() },
|
|
208
|
+
body: JSON.stringify({ agentId, team, identity: identityOf(cfg) }),
|
|
209
|
+
});
|
|
210
|
+
if (!res.ok) throw new Error(`pitch join: ${res.status} ${await res.text()}`);
|
|
211
|
+
const data = (await res.json()) as any;
|
|
212
|
+
if (typeof data.did === "string") rememberDid(cfg, data.did);
|
|
213
|
+
if (typeof data.token === "string") rememberToken(cfg, data.token);
|
|
214
|
+
return data;
|
|
215
|
+
},
|
|
216
|
+
async credits(agentId: string): Promise<unknown> {
|
|
217
|
+
const res = await fetch(`${base}/agents/${encodeURIComponent(agentId)}/credits`);
|
|
218
|
+
if (!res.ok) throw new Error(`pitch credits: ${res.status} ${await res.text()}`);
|
|
219
|
+
return res.json();
|
|
220
|
+
},
|
|
221
|
+
async skinCatalog(): Promise<unknown> {
|
|
222
|
+
const res = await fetch(`${base}/skins`);
|
|
223
|
+
if (!res.ok) throw new Error(`pitch skins: ${res.status} ${await res.text()}`);
|
|
224
|
+
return res.json();
|
|
225
|
+
},
|
|
226
|
+
async mySkins(agentId: string): Promise<unknown> {
|
|
227
|
+
const res = await fetch(`${base}/agents/${encodeURIComponent(agentId)}/skins`);
|
|
228
|
+
if (!res.ok) throw new Error(`pitch my skins: ${res.status} ${await res.text()}`);
|
|
229
|
+
return res.json();
|
|
230
|
+
},
|
|
231
|
+
async buySkin(agentId: string, skinId: string): Promise<unknown> {
|
|
232
|
+
const res = await fetch(`${base}/agents/${encodeURIComponent(agentId)}/skins/buy`, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
235
|
+
body: JSON.stringify({ skinId }),
|
|
236
|
+
});
|
|
237
|
+
if (!res.ok) throw new Error(`pitch buy skin: ${res.status} ${await res.text()}`);
|
|
238
|
+
return res.json();
|
|
239
|
+
},
|
|
240
|
+
async equipSkin(agentId: string, skinId: string | null): Promise<unknown> {
|
|
241
|
+
const res = await fetch(`${base}/agents/${encodeURIComponent(agentId)}/skins/equip`, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
244
|
+
body: JSON.stringify({ skinId }),
|
|
245
|
+
});
|
|
246
|
+
if (!res.ok) throw new Error(`pitch equip skin: ${res.status} ${await res.text()}`);
|
|
247
|
+
return res.json();
|
|
248
|
+
},
|
|
249
|
+
async renamePlayer(agentId: string, player: string, name: string): Promise<unknown> {
|
|
250
|
+
const res = await fetch(`${base}/matches/${await m()}/players/${encodeURIComponent(player)}/name`, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
253
|
+
body: JSON.stringify({ agentId, name }),
|
|
254
|
+
});
|
|
255
|
+
if (!res.ok) throw new Error(`pitch rename: ${res.status} ${await res.text()}`);
|
|
256
|
+
return res.json();
|
|
257
|
+
},
|
|
258
|
+
async setIdentity(agentId: string, patch: Record<string, unknown>): Promise<unknown> {
|
|
259
|
+
const res = await fetch(`${base}/matches/${await m()}/identity`, {
|
|
260
|
+
method: "POST",
|
|
261
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
262
|
+
body: JSON.stringify({ agentId, ...patch }),
|
|
263
|
+
});
|
|
264
|
+
if (!res.ok) throw new Error(`pitch identity: ${res.status} ${await res.text()}`);
|
|
265
|
+
return res.json();
|
|
266
|
+
},
|
|
267
|
+
async teamState(agentId: string): Promise<TeamView> {
|
|
268
|
+
const res = await fetch(`${base}/matches/${await m()}/agents/${encodeURIComponent(agentId)}/state`);
|
|
269
|
+
if (!res.ok) throw new Error(`pitch team state: ${res.status} ${await res.text()}`);
|
|
270
|
+
return res.json() as Promise<TeamView>;
|
|
271
|
+
},
|
|
272
|
+
async action(player: string, body: Record<string, unknown>): Promise<unknown> {
|
|
273
|
+
const tagged = session.lockstep ? { ...body, turn: session.turn } : body;
|
|
274
|
+
const res = await fetch(`${base}/matches/${await m()}/players/${encodeURIComponent(player)}/action`, {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
277
|
+
body: JSON.stringify(tagged),
|
|
278
|
+
});
|
|
279
|
+
if (!res.ok) throw new Error(`pitch action: ${res.status} ${await res.text()}`);
|
|
280
|
+
return res.json();
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const ok = (data: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(data) }] });
|
|
286
|
+
const err = (msg: string) => ({ content: [{ type: "text" as const, text: `error: ${msg}` }], isError: true });
|
|
287
|
+
|
|
288
|
+
/** Resolve which player a tool call targets: explicit `player`, or the single
|
|
289
|
+
* claimed player when this agent controls exactly one. */
|
|
290
|
+
function target(player?: string): { id: string } | { error: string } {
|
|
291
|
+
if (player) {
|
|
292
|
+
if (session.players.length && !session.players.includes(player)) {
|
|
293
|
+
return { error: `you don't control "${player}". Your players: ${session.players.join(", ")}` };
|
|
294
|
+
}
|
|
295
|
+
return { id: player };
|
|
296
|
+
}
|
|
297
|
+
if (session.players.length === 1) return { id: session.players[0]! };
|
|
298
|
+
return { error: `specify player="<id>" — you control: ${session.players.join(", ") || "(none yet)"}` };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const playerParam = Type.Optional(
|
|
302
|
+
Type.String({ description: "Which of your players to move (id, e.g. home-9). Omit only if you control exactly one." }),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// ── Matchmaking: how a human (chatting with their agent) gets into a game. ──
|
|
306
|
+
function lobbyTools(cfg: PluginCfg): AnyAgentTool[] {
|
|
307
|
+
const client = pitchClient(cfg);
|
|
308
|
+
const agentId = agentIdOf(cfg);
|
|
309
|
+
const fmt = (r: { id: string; status: string; teamSize: number; score: { home: number; away: number }; sides: { home: string | null; away: string | null } }) =>
|
|
310
|
+
`${r.id}: ${r.teamSize}v${r.teamSize} [${r.status}] ${r.score.home}-${r.score.away} home=${r.sides.home ?? "OPEN"} away=${r.sides.away ?? "OPEN"}`;
|
|
311
|
+
const joinVia = async (matchId: string, team?: "home" | "away") => {
|
|
312
|
+
// In the gateway process the service installed joinAndWatch (joins AND
|
|
313
|
+
// starts the playing loop). In a chat process there is no watcher — just
|
|
314
|
+
// take the seat on the server; the gateway's seat-poller will notice the
|
|
315
|
+
// seat within seconds and start playing.
|
|
316
|
+
if (session.joinAndWatch) return session.joinAndWatch(matchId, team);
|
|
317
|
+
const seat = await client.join(matchId, agentId, team);
|
|
318
|
+
session.matchId = matchId;
|
|
319
|
+
session.players = seat.playerIds;
|
|
320
|
+
return { ...seat, started: seat.started };
|
|
321
|
+
};
|
|
322
|
+
return [
|
|
323
|
+
{
|
|
324
|
+
name: "soccer_find_matches",
|
|
325
|
+
label: "Find matches",
|
|
326
|
+
description:
|
|
327
|
+
"List open soccer rooms (the lobby): id, format (e.g. 5v5 or 11v11), status, score, which sides are OPEN. Use when the human asks to find/watch/join a game.",
|
|
328
|
+
parameters: Type.Object({
|
|
329
|
+
teamSize: Type.Optional(Type.Number({ description: "filter by players per side, e.g. 5 or 11" })),
|
|
330
|
+
}),
|
|
331
|
+
async execute(_id, params) {
|
|
332
|
+
const { matches } = await client.lobby();
|
|
333
|
+
const list = matches.filter(r => !params.teamSize || r.teamSize === params.teamSize);
|
|
334
|
+
return ok({ matches: list.map(fmt), hint: list.some(r => r.status === "waiting") ? "join a waiting room with soccer_join_match" : "no open rooms — create one with soccer_create_match" });
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
name: "soccer_create_match",
|
|
339
|
+
label: "Create a match",
|
|
340
|
+
description:
|
|
341
|
+
"Create a new room and take a side in it. The match starts automatically when an opponent joins. Use when the human says 'create a 5v5 game', 'start an 11-player match', etc.",
|
|
342
|
+
parameters: Type.Object({
|
|
343
|
+
teamSize: Type.Number({ minimum: 1, maximum: 11, description: "players per side (5 = five-a-side, 11 = standard)" }),
|
|
344
|
+
maxGoals: Type.Optional(Type.Number({ description: "match ends when total goals exceed this (default 5)" })),
|
|
345
|
+
team: Type.Optional(Type.Union([Type.Literal("home"), Type.Literal("away")])),
|
|
346
|
+
}),
|
|
347
|
+
async execute(_id, params) {
|
|
348
|
+
const { id } = await client.createRoom({ teamSize: params.teamSize, ...(params.maxGoals ? { maxGoals: params.maxGoals } : {}) });
|
|
349
|
+
const seat = await joinVia(id, params.team);
|
|
350
|
+
return ok({ matchId: id, ...seat, note: "waiting for an opponent — the match starts automatically when one joins. GIVE YOUR HUMAN the managerUrl link: it's their manager console for this room (pause/reset votes, room controls)." });
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
name: "soccer_join_match",
|
|
355
|
+
label: "Join a match",
|
|
356
|
+
description:
|
|
357
|
+
"Join a room and control a WHOLE side. With matchId: join that room. Without: quick-match — join any waiting room (optionally filtered by teamSize), or create one if none. The match starts automatically when both sides are taken.",
|
|
358
|
+
parameters: Type.Object({
|
|
359
|
+
matchId: Type.Optional(Type.String()),
|
|
360
|
+
teamSize: Type.Optional(Type.Number({ description: "quick-match filter / size for a new room (default 5)" })),
|
|
361
|
+
team: Type.Optional(Type.Union([Type.Literal("home"), Type.Literal("away")])),
|
|
362
|
+
}),
|
|
363
|
+
async execute(_id, params) {
|
|
364
|
+
let id = params.matchId;
|
|
365
|
+
if (!id) {
|
|
366
|
+
// atomic server-side find-or-create — two racing agents land in ONE room
|
|
367
|
+
const q = await client.quickMatch(agentId, { ...(params.teamSize ? { teamSize: params.teamSize } : {}), ...(params.team ? { team: params.team } : {}) });
|
|
368
|
+
id = q.matchId;
|
|
369
|
+
}
|
|
370
|
+
const seat = await joinVia(id, params.team);
|
|
371
|
+
return ok({ matchId: id, ...seat, note: (seat.started ? "opponent present — playing now!" : "seated — match starts when an opponent joins") + " GIVE YOUR HUMAN the managerUrl link: it's their manager console for this room." });
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
name: "soccer_credits",
|
|
376
|
+
label: "Check credits",
|
|
377
|
+
description:
|
|
378
|
+
"Check your membership status and what it unlocks. Playing and shouting are always free; membership unlocks the cosmetic perks — team skins, runtime identity changes, and player renames.",
|
|
379
|
+
parameters: Type.Object({}),
|
|
380
|
+
async execute() { return ok(await client.credits(agentId)); },
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: "soccer_skin",
|
|
384
|
+
label: "Team skins",
|
|
385
|
+
description:
|
|
386
|
+
"Browse, claim, and equip team kits (skins) — cosmetic only, shown to everyone watching the match. 'list' shows the catalog + what you own, 'buy' claims a kit (members-only — membership unlocks all skins, free), 'equip' wears one (or pass no skinId to revert to the default kit). Use when the human says 'change our kit', 'wear the volt skin', 'wear something cool'.",
|
|
387
|
+
parameters: Type.Object({
|
|
388
|
+
action: Type.Union([Type.Literal("list"), Type.Literal("buy"), Type.Literal("equip")]),
|
|
389
|
+
skinId: Type.Optional(Type.String({ description: "skin id from the catalog (required for buy; omit on equip to revert to default)" })),
|
|
390
|
+
}),
|
|
391
|
+
async execute(_id, params) {
|
|
392
|
+
if (params.action === "list") {
|
|
393
|
+
const [catalog, mine] = await Promise.all([client.skinCatalog(), client.mySkins(agentId)]);
|
|
394
|
+
return ok({ catalog, mine });
|
|
395
|
+
}
|
|
396
|
+
if (params.action === "buy") {
|
|
397
|
+
if (!params.skinId) return err("skinId required to buy");
|
|
398
|
+
return ok(await client.buySkin(agentId, params.skinId));
|
|
399
|
+
}
|
|
400
|
+
return ok(await client.equipSkin(agentId, params.skinId ?? null));
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
name: "agentnet_claim_owner",
|
|
405
|
+
label: "Claim owner",
|
|
406
|
+
description:
|
|
407
|
+
"Link this agent to its human owner's AgentNet account. The human gets a one-time code from the AgentNet site ('Claim my agent') and tells it to you — e.g. 'claim me on AgentNet with code 7F3K-92'. Redeems the code using this agent's own API key; the human never handles the key.",
|
|
408
|
+
parameters: Type.Object({
|
|
409
|
+
code: Type.String({ description: "the one-time claim code the human read out, e.g. 7F3K-92AB" }),
|
|
410
|
+
}),
|
|
411
|
+
async execute(_id, params) {
|
|
412
|
+
const key = apiKeyOf(cfg);
|
|
413
|
+
if (!key) return err("no AgentNet API key configured (set plugin apiKey or AGENTNET_API_KEY)");
|
|
414
|
+
const base = (cfg.accountsUrl ?? "http://localhost:3005").replace(/\/$/, "");
|
|
415
|
+
const res = await fetch(`${base}/agents/claim`, {
|
|
416
|
+
method: "POST",
|
|
417
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
418
|
+
body: JSON.stringify({ code: params.code }),
|
|
419
|
+
});
|
|
420
|
+
const data = (await res.json().catch(() => ({}))) as any;
|
|
421
|
+
if (!res.ok) return err(`claim failed: ${data.error ?? res.status}`);
|
|
422
|
+
return ok({ ...data, note: "linked! your owner's membership now unlocks skins/renames/identity for this agent" });
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
name: "soccer_rename_player",
|
|
427
|
+
label: "Rename a player",
|
|
428
|
+
description:
|
|
429
|
+
"Give one of YOUR players a display name shown on the pitch (≤12 chars) — when the human says 'call our striker 梅西二世'. Only works for players you control. MEMBERS-ONLY: membership unlocks player renames.",
|
|
430
|
+
parameters: Type.Object({
|
|
431
|
+
player: Type.String({ description: "player id, e.g. home-9" }),
|
|
432
|
+
name: Type.String({ description: "new display name, ≤12 chars" }),
|
|
433
|
+
}),
|
|
434
|
+
async execute(_id, params) {
|
|
435
|
+
return ok(await client.renamePlayer(agentId, params.player, params.name));
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
name: "soccer_set_identity",
|
|
440
|
+
label: "Set team identity",
|
|
441
|
+
description:
|
|
442
|
+
"Change your team's identity at RUNTIME — when the human says 'rename our team to 风暴', 'set our clan to 魔兽工会', 'we play 意大利防守反击'. nation is an ISO code shown as a flag. style shapes how you play. MEMBERS-ONLY mid-match (identity set in config at join time is free for everyone).",
|
|
443
|
+
parameters: Type.Object({
|
|
444
|
+
name: Type.Optional(Type.String({ description: "team name (≤24 chars)" })),
|
|
445
|
+
nation: Type.Optional(Type.String({ description: "ISO country code for the flag, e.g. NL, IT, CN" })),
|
|
446
|
+
clan: Type.Optional(Type.String({ description: "clan/guild tag (≤24 chars)" })),
|
|
447
|
+
style: Type.Optional(Type.String({ description: "playing style that will guide your decisions" })),
|
|
448
|
+
}),
|
|
449
|
+
async execute(_id, params) {
|
|
450
|
+
const data = await client.setIdentity(agentId, params as Record<string, unknown>);
|
|
451
|
+
return ok(data);
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
] as AnyAgentTool[];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function observeTool(cfg: PluginCfg, mode: "easy" | "advanced" | "both"): AnyAgentTool {
|
|
458
|
+
const client = pitchClient(cfg);
|
|
459
|
+
const agentId = agentIdOf(cfg);
|
|
460
|
+
return {
|
|
461
|
+
name: "soccer_observe",
|
|
462
|
+
label: "Observe the pitch",
|
|
463
|
+
description:
|
|
464
|
+
"See your whole side: each player you control, the ball, teammates, opponents, score. Call this before deciding moves, then issue one action per player.",
|
|
465
|
+
parameters: Type.Object({}),
|
|
466
|
+
async execute() {
|
|
467
|
+
const v = await client.teamState(agentId);
|
|
468
|
+
return { content: [{ type: "text", text: `${describeTeam(v, mode)}\n\n${JSON.stringify(v)}` }] };
|
|
469
|
+
},
|
|
470
|
+
} as AnyAgentTool;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── Easy tier: high-level intents; the SERVER computes the geometry. ──
|
|
474
|
+
function easyTools(cfg: PluginCfg): AnyAgentTool[] {
|
|
475
|
+
const client = pitchClient(cfg);
|
|
476
|
+
const act = (player: string | undefined, body: Record<string, unknown>) => {
|
|
477
|
+
const t = target(player);
|
|
478
|
+
return "error" in t ? Promise.resolve(err(t.error)) : client.action(t.id, body).then(ok);
|
|
479
|
+
};
|
|
480
|
+
return [
|
|
481
|
+
{
|
|
482
|
+
name: "soccer_chase_ball",
|
|
483
|
+
label: "Chase the ball",
|
|
484
|
+
description: "Make one of your players run to win the ball (server leads the moving ball). Use when that player does NOT have it.",
|
|
485
|
+
parameters: Type.Object({ player: playerParam }),
|
|
486
|
+
async execute(_id, params) { return act(params.player, { type: "chase" }); },
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
name: "soccer_shoot",
|
|
490
|
+
label: "Shoot at goal",
|
|
491
|
+
description: "Shoot at the opponent goal. The chosen player must have the ball.",
|
|
492
|
+
parameters: Type.Object({ player: playerParam }),
|
|
493
|
+
async execute(_id, params) { return act(params.player, { type: "shoot" }); },
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
name: "soccer_dribble",
|
|
497
|
+
label: "Dribble toward goal",
|
|
498
|
+
description: "Carry the ball toward goal; side veers to beat a defender. The chosen player must have the ball.",
|
|
499
|
+
parameters: Type.Object({
|
|
500
|
+
player: playerParam,
|
|
501
|
+
side: Type.Union([Type.Literal("left"), Type.Literal("right"), Type.Literal("forward")]),
|
|
502
|
+
}),
|
|
503
|
+
async execute(_id, params) { return act(params.player, { type: "dribble", side: params.side }); },
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
name: "soccer_pass",
|
|
507
|
+
label: "Pass to a teammate",
|
|
508
|
+
description: "Pass to the best teammate ahead (or clear forward). The chosen player must have the ball.",
|
|
509
|
+
parameters: Type.Object({ player: playerParam }),
|
|
510
|
+
async execute(_id, params) { return act(params.player, { type: "pass" }); },
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
name: "soccer_press",
|
|
514
|
+
label: "Press the carrier",
|
|
515
|
+
description: "Explicitly send a player to close down the ball carrier tight (goal-side). Combine with cover/defend to shape your defence — e.g. two pressers for an aggressive press.",
|
|
516
|
+
parameters: Type.Object({ player: playerParam }),
|
|
517
|
+
async execute(_id, params) { return act(params.player, { type: "press" }); },
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
name: "soccer_cover",
|
|
521
|
+
label: "Cover behind the press",
|
|
522
|
+
description: "Explicitly position a player as the second defender — deeper on the carrier-goal line, catching the carrier if your presser is beaten.",
|
|
523
|
+
parameters: Type.Object({ player: playerParam }),
|
|
524
|
+
async execute(_id, params) { return act(params.player, { type: "cover" }); },
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
name: "soccer_defend",
|
|
528
|
+
label: "Defend / contain",
|
|
529
|
+
description: "Have a player defend: the server keeps it goal-side of the ball carrier and contains it (no ball needed). Use for players off the ball when the opponent has it.",
|
|
530
|
+
parameters: Type.Object({ player: playerParam }),
|
|
531
|
+
async execute(_id, params) { return act(params.player, { type: "defend" }); },
|
|
532
|
+
},
|
|
533
|
+
] as AnyAgentTool[];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── Advanced tier: raw run/kick; the AGENT computes the geometry (this +x frame). ──
|
|
537
|
+
function advancedTools(cfg: PluginCfg): AnyAgentTool[] {
|
|
538
|
+
const client = pitchClient(cfg);
|
|
539
|
+
const act = (player: string | undefined, body: Record<string, unknown>) => {
|
|
540
|
+
const t = target(player);
|
|
541
|
+
return "error" in t ? Promise.resolve(err(t.error)) : client.action(t.id, body).then(ok);
|
|
542
|
+
};
|
|
543
|
+
return [
|
|
544
|
+
{
|
|
545
|
+
name: "soccer_run",
|
|
546
|
+
label: "Run",
|
|
547
|
+
description: "Run a set DISTANCE (metres) toward dir, then stop. dir is in your +x attacking frame.",
|
|
548
|
+
parameters: Type.Object({
|
|
549
|
+
player: playerParam,
|
|
550
|
+
dirX: Type.Number(), dirY: Type.Number(),
|
|
551
|
+
distance: Type.Number({ minimum: 0, maximum: 105 }),
|
|
552
|
+
}),
|
|
553
|
+
async execute(_id, params) {
|
|
554
|
+
return act(params.player, { type: "run", dir: { x: Number(params.dirX) || 0, y: Number(params.dirY) || 0 }, distance: params.distance });
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
name: "soccer_kick",
|
|
559
|
+
label: "Kick",
|
|
560
|
+
description: "Kick the ball (player must have it). power 0..1 (~50m). dir is in your +x attacking frame.",
|
|
561
|
+
parameters: Type.Object({
|
|
562
|
+
player: playerParam,
|
|
563
|
+
dirX: Type.Number(), dirY: Type.Number(),
|
|
564
|
+
power: Type.Number({ minimum: 0, maximum: 1 }),
|
|
565
|
+
}),
|
|
566
|
+
async execute(_id, params) {
|
|
567
|
+
return act(params.player, { type: "kick", dir: { x: Number(params.dirX) || 0, y: Number(params.dirY) || 0 }, power: params.power });
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
] as AnyAgentTool[];
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function moveToBody(m: Record<string, unknown>): Record<string, unknown> {
|
|
574
|
+
switch (m.action) {
|
|
575
|
+
case "chase": return { type: "chase" };
|
|
576
|
+
case "shoot": return { type: "shoot" };
|
|
577
|
+
case "dribble": return { type: "dribble", side: (m.side as string) ?? "forward" };
|
|
578
|
+
case "pass": return { type: "pass" };
|
|
579
|
+
case "defend": return { type: "defend" };
|
|
580
|
+
case "press": return { type: "press" };
|
|
581
|
+
case "cover": return { type: "cover" };
|
|
582
|
+
case "stop": return { type: "stop" };
|
|
583
|
+
case "run": return { type: "run", dir: { x: Number(m.dirX) || 0, y: Number(m.dirY) || 0 }, distance: Number(m.distance) || 0 };
|
|
584
|
+
case "kick": return { type: "kick", dir: { x: Number(m.dirX) || 0, y: Number(m.dirY) || 0 }, power: Number(m.power) ?? 1 };
|
|
585
|
+
default: return { type: "stop" };
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// One tool call that moves EVERY player you control — a move per player. This
|
|
590
|
+
// is the preferred way to play a multi-player side: one call instead of N.
|
|
591
|
+
function playTool(cfg: PluginCfg, mode: "easy" | "advanced" | "both", spec: GameSpec | null = null): AnyAgentTool {
|
|
592
|
+
const client = pitchClient(cfg);
|
|
593
|
+
// Phase 4: the action enum is GENERATED from the /spec manifest when reachable
|
|
594
|
+
// (a new server action surfaces here with no plugin edit), else static fallback.
|
|
595
|
+
const acts = playActionTypes(spec, mode);
|
|
596
|
+
const move = Type.Object({
|
|
597
|
+
player: Type.String({ description: "one of your players (id, e.g. home-9)" }),
|
|
598
|
+
action: Type.Union(acts.map((a) => Type.Literal(a)), { description: "what that player should do" }),
|
|
599
|
+
side: Type.Optional(Type.Union([Type.Literal("left"), Type.Literal("right"), Type.Literal("forward")], { description: "for dribble" })),
|
|
600
|
+
dirX: Type.Optional(Type.Number()), dirY: Type.Optional(Type.Number()),
|
|
601
|
+
distance: Type.Optional(Type.Number()), power: Type.Optional(Type.Number()),
|
|
602
|
+
say: Type.Optional(Type.String({ description: "short in-character shout shown over this player (≤60 chars). FREE for everyone — banter makes the match fun to watch" })),
|
|
603
|
+
});
|
|
604
|
+
const specHints = spec?.actions.descriptions
|
|
605
|
+
? acts.filter((a) => spec.actions.descriptions![a]).map((a) => `${a}: ${spec.actions.descriptions![a]}`).join("; ")
|
|
606
|
+
: "";
|
|
607
|
+
return {
|
|
608
|
+
name: "soccer_play",
|
|
609
|
+
label: "Move all players",
|
|
610
|
+
description:
|
|
611
|
+
"Set actions for ALL the players you control in ONE call: pass moves=[{player, action, ...}] with one entry per player. Each move may include say: a short shout your player yells on the pitch (spectators see it — give your team a voice and personality!). Prefer this over calling a per-player tool N times. " +
|
|
612
|
+
`action ∈ ${acts.join("|")}.` +
|
|
613
|
+
(acts.includes("run") || acts.includes("kick") ? " (run/kick need dirX,dirY + distance/power; dribble takes side)." : " (dribble takes side).") +
|
|
614
|
+
(specHints ? " Actions — " + specHints : ""),
|
|
615
|
+
parameters: Type.Object({ moves: Type.Array(move, { description: "one move per player you control" }) }),
|
|
616
|
+
async execute(_id, params) {
|
|
617
|
+
const applied: unknown[] = [];
|
|
618
|
+
for (const m of params.moves as Record<string, unknown>[]) {
|
|
619
|
+
const t = target(m.player as string);
|
|
620
|
+
if ("error" in t) { applied.push({ player: m.player, error: t.error }); continue; }
|
|
621
|
+
try {
|
|
622
|
+
const body = moveToBody(m);
|
|
623
|
+
if (typeof m.say === "string" && m.say.trim()) body.say = m.say;
|
|
624
|
+
await client.action(t.id, body);
|
|
625
|
+
applied.push({ player: t.id, action: m.action });
|
|
626
|
+
}
|
|
627
|
+
catch (e) { applied.push({ player: m.player, error: String(e) }); }
|
|
628
|
+
}
|
|
629
|
+
return ok({ applied });
|
|
630
|
+
},
|
|
631
|
+
} as AnyAgentTool;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
// ── Multi-venue (marketplace-unification.md §5.3) ─────────────────────────────
|
|
636
|
+
// The plugin is a PLATFORM client: venues (games AND work markets) come from
|
|
637
|
+
// the registry; each venue's spec publishes actions + ROUTE TEMPLATES, so
|
|
638
|
+
// acting on a venue needs zero venue-specific code.
|
|
639
|
+
|
|
640
|
+
const VENUE_DEFAULTS: Record<string, string> = { taskmarket: "http://localhost:3030" };
|
|
641
|
+
const venueSpecs = new Map<string, GameSpec & { routes?: Record<string, string> }>();
|
|
642
|
+
|
|
643
|
+
/** Test seam: drop cached venue specs. */
|
|
644
|
+
export function _resetVenueCache(): void { venueSpecs.clear(); }
|
|
645
|
+
|
|
646
|
+
function venueUrl(origin: string, cfg: PluginCfg): string {
|
|
647
|
+
if (origin.startsWith("http://") || origin.startsWith("https://")) return origin;
|
|
648
|
+
if (origin === "pitch") return (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
|
|
649
|
+
return process.env[`AGENTNET_${origin.toUpperCase()}_URL`] ?? VENUE_DEFAULTS[origin] ?? (cfg.serverUrl ?? "http://localhost:3010");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function venueSpec(origin: string, cfg: PluginCfg): Promise<(GameSpec & { routes?: Record<string, string> }) | null> {
|
|
653
|
+
const cached = venueSpecs.get(origin);
|
|
654
|
+
if (cached) return cached;
|
|
655
|
+
try {
|
|
656
|
+
const res = await fetch(`${venueUrl(origin, cfg)}/spec`);
|
|
657
|
+
if (!res.ok) return null;
|
|
658
|
+
const spec = (await res.json()) as GameSpec & { routes?: Record<string, string> };
|
|
659
|
+
if (!spec || !Array.isArray(spec.actions?.enum)) return null;
|
|
660
|
+
venueSpecs.set(origin, spec);
|
|
661
|
+
return spec;
|
|
662
|
+
} catch { return null; }
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function venueTools(cfg: PluginCfg): AnyAgentTool[] {
|
|
666
|
+
const base = (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
|
|
667
|
+
const did = () => agentIdOf(cfg);
|
|
668
|
+
return [
|
|
669
|
+
{
|
|
670
|
+
name: "venues",
|
|
671
|
+
label: "Platform venues",
|
|
672
|
+
description: "List every venue on the AgentNet platform — games (agent-soccer; golf later) and work marketplaces (taskmarket: agent-to-agent paid tasks). Use to discover where you can play or earn.",
|
|
673
|
+
parameters: Type.Object({}),
|
|
674
|
+
async execute() {
|
|
675
|
+
try {
|
|
676
|
+
const res = await fetch(`${base}/platform/marketplaces`);
|
|
677
|
+
if (!res.ok) return ok({ error: `registry unavailable (${res.status})` });
|
|
678
|
+
const { marketplaces } = (await res.json()) as { marketplaces: Record<string, unknown>[] };
|
|
679
|
+
return ok({ venues: marketplaces.map(v => ({ id: v["id"], name: v["name"], kind: v["kind"], origin: v["origin"], feeBps: v["feeBps"], status: v["status"] })),
|
|
680
|
+
hint: "games: soccer_* tools. work: work_observe to see the market, work_act to post/bid/deliver/confirm." });
|
|
681
|
+
} catch (e) { return ok({ error: String(e) }); }
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
name: "work_observe",
|
|
686
|
+
label: "Observe the task market",
|
|
687
|
+
description: "Look at the task marketplace from your point of view: events since your cursor, tasks you posted (with bids), tasks you're working, and what's open for bidding — plus a summary and per-task legalActions. Call before work_act.",
|
|
688
|
+
parameters: Type.Object({ cursor: Type.Optional(Type.Number({ description: "event cursor from your previous observe (default 0)" })) }),
|
|
689
|
+
async execute(_id, params) {
|
|
690
|
+
const spec = await venueSpec("taskmarket", cfg);
|
|
691
|
+
if (!spec) return ok({ error: "taskmarket venue unreachable — try again later" });
|
|
692
|
+
const path = String(spec.routes?.["observe"] ?? "/agents/{did}/observe").replace("{did}", did());
|
|
693
|
+
try {
|
|
694
|
+
const res = await fetch(`${venueUrl("taskmarket", cfg)}${path}?cursor=${Number(params.cursor ?? 0)}`, { headers: { "x-caller-did": did() } });
|
|
695
|
+
const body = await res.json();
|
|
696
|
+
if (!res.ok) return ok({ error: (body as { error?: string }).error ?? `observe failed (${res.status})` });
|
|
697
|
+
return ok({ ...(body as Record<string, unknown>), hint: "act with work_act; only actions in a task's legalActions will succeed." });
|
|
698
|
+
} catch (e) { return ok({ error: String(e) }); }
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
name: "work_act",
|
|
703
|
+
label: "Act in the task market",
|
|
704
|
+
description: "Act in the task marketplace: post a task (title/description/budget), bid (taskId/price/message), accept a bid (taskId/bidId), deliver (taskId/result), confirm/cancel/dispute (taskId). Escrow and payment are platform-handled.",
|
|
705
|
+
parameters: Type.Object({
|
|
706
|
+
action: Type.String({ description: "one of the venue's published actions" }),
|
|
707
|
+
taskId: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
708
|
+
description: Type.Optional(Type.String()), budget: Type.Optional(Type.Number()),
|
|
709
|
+
price: Type.Optional(Type.Number()), message: Type.Optional(Type.String()),
|
|
710
|
+
etaHours: Type.Optional(Type.Number()), bidId: Type.Optional(Type.String()),
|
|
711
|
+
result: Type.Optional(Type.String()),
|
|
712
|
+
}),
|
|
713
|
+
async execute(_id, params) {
|
|
714
|
+
const spec = await venueSpec("taskmarket", cfg);
|
|
715
|
+
if (!spec) return ok({ error: "taskmarket venue unreachable — try again later" });
|
|
716
|
+
const enumList = spec.actions.enum;
|
|
717
|
+
if (!enumList.includes(params.action)) return ok({ error: `action must be one of ${JSON.stringify(enumList)}` });
|
|
718
|
+
const path = String(spec.routes?.["act"] ?? "/agents/{did}/action").replace("{did}", did());
|
|
719
|
+
const body: Record<string, unknown> = { type: params.action };
|
|
720
|
+
for (const k of ["taskId", "title", "description", "budget", "price", "message", "etaHours", "bidId", "result"] as const) {
|
|
721
|
+
if (params[k] !== undefined) body[k] = params[k];
|
|
722
|
+
}
|
|
723
|
+
try {
|
|
724
|
+
const res = await fetch(`${venueUrl("taskmarket", cfg)}${path}`, {
|
|
725
|
+
method: "POST", headers: { "content-type": "application/json", "x-caller-did": did() }, body: JSON.stringify(body),
|
|
726
|
+
});
|
|
727
|
+
const out = await res.json();
|
|
728
|
+
if (!res.ok) return ok({ error: (out as { error?: string }).error ?? `act failed (${res.status})`, ...(out as Record<string, unknown>) });
|
|
729
|
+
return ok({ result: out, hint: "work_observe again to see the new state." });
|
|
730
|
+
} catch (e) { return ok({ error: String(e) }); }
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
];
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export function createSoccerTools(api: OpenClawPluginApi, spec: GameSpec | null = null): AnyAgentTool[] {
|
|
737
|
+
const cfg = (api.pluginConfig ?? {}) as PluginCfg;
|
|
738
|
+
const mode = cfg.mode ?? "easy";
|
|
739
|
+
|
|
740
|
+
const tools: AnyAgentTool[] = [...lobbyTools(cfg), observeTool(cfg, mode), playTool(cfg, mode, spec), ...venueTools(cfg)];
|
|
741
|
+
if (mode === "easy" || mode === "both") tools.push(...easyTools(cfg));
|
|
742
|
+
if (mode === "advanced" || mode === "both") tools.push(...advancedTools(cfg));
|
|
743
|
+
|
|
744
|
+
const client = pitchClient(cfg);
|
|
745
|
+
tools.push({
|
|
746
|
+
name: "soccer_stop",
|
|
747
|
+
label: "Stop",
|
|
748
|
+
description: "Stop one of your players (clears its standing order).",
|
|
749
|
+
parameters: Type.Object({ player: playerParam }),
|
|
750
|
+
async execute(_id, params) {
|
|
751
|
+
const t = target(params.player);
|
|
752
|
+
return "error" in t ? err(t.error) : ok(await client.action(t.id, { type: "stop" }));
|
|
753
|
+
},
|
|
754
|
+
} as AnyAgentTool);
|
|
755
|
+
|
|
756
|
+
return tools;
|
|
757
|
+
}
|