@ape-church/skill 1.0.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/CHANGELOG.md +43 -0
- package/HEARTBEAT.md +50 -0
- package/PUBLISHING.md +142 -0
- package/SKILL.md +177 -0
- package/STRATEGY.md +69 -0
- package/agent_nodes.md +144 -0
- package/assets/HEARTBEAT.md +50 -0
- package/assets/SKILL.md +177 -0
- package/assets/STRATEGY.md +69 -0
- package/assets/skill.json +13 -0
- package/bin/cli.js +1247 -0
- package/example_log_filtering.js +113 -0
- package/example_play_via_contract.js +496 -0
- package/package.json +26 -0
- package/profile.example.json +20 -0
- package/registry.js +68 -0
- package/skill.json +13 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,1247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import {
|
|
8
|
+
createPublicClient,
|
|
9
|
+
createWalletClient,
|
|
10
|
+
encodeAbiParameters,
|
|
11
|
+
formatEther,
|
|
12
|
+
http,
|
|
13
|
+
parseEther,
|
|
14
|
+
webSocket,
|
|
15
|
+
} from 'viem';
|
|
16
|
+
import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
|
|
17
|
+
import { apechain } from 'viem/chains';
|
|
18
|
+
import { SiweMessage } from 'siwe';
|
|
19
|
+
import { GAME_REGISTRY, listGames, resolveGame } from '../registry.js';
|
|
20
|
+
|
|
21
|
+
const program = new Command();
|
|
22
|
+
const PACKAGE_VERSION = (() => {
|
|
23
|
+
try {
|
|
24
|
+
const pkgUrl = new URL('../package.json', import.meta.url);
|
|
25
|
+
const pkg = JSON.parse(fs.readFileSync(pkgUrl, 'utf8'));
|
|
26
|
+
return pkg.version || '0.0.0';
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return '0.0.0';
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
|
|
32
|
+
program.name('apechurch').version(PACKAGE_VERSION, '-v, --version', 'output the current version');
|
|
33
|
+
const HOME = os.homedir();
|
|
34
|
+
// Standard OpenClaw skills directory
|
|
35
|
+
const SKILL_TARGET_DIR = path.join(HOME, '.openclaw', 'skills', 'ape-church');
|
|
36
|
+
const WALLET_FILE = path.join(HOME, '.apechurch-wallet.json');
|
|
37
|
+
const APECHURCH_DIR = path.join(HOME, '.apechurch');
|
|
38
|
+
const STATE_FILE = path.join(APECHURCH_DIR, 'state.json');
|
|
39
|
+
const PROFILE_FILE = path.join(APECHURCH_DIR, 'profile.json');
|
|
40
|
+
|
|
41
|
+
const GAS_RESERVE_APE = 1;
|
|
42
|
+
const DEFAULT_COOLDOWN_MS = 30 * 1000; // 30 seconds default
|
|
43
|
+
const PROFILE_API_URL =
|
|
44
|
+
process.env.APECHURCH_PROFILE_URL || 'https://www.ape.church/api/profile';
|
|
45
|
+
const SIWE_DOMAIN = 'ape.church';
|
|
46
|
+
const SIWE_URI = 'https://ape.church';
|
|
47
|
+
const SIWE_CHAIN_ID = 33139;
|
|
48
|
+
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
|
|
49
|
+
const GAME_LIST = listGames().join(' | ');
|
|
50
|
+
|
|
51
|
+
const GAME_CONTRACT_ABI = [
|
|
52
|
+
{
|
|
53
|
+
type: 'function',
|
|
54
|
+
name: 'play',
|
|
55
|
+
stateMutability: 'payable',
|
|
56
|
+
inputs: [
|
|
57
|
+
{ name: 'player', type: 'address' },
|
|
58
|
+
{ name: 'gameData', type: 'bytes' },
|
|
59
|
+
],
|
|
60
|
+
outputs: [],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: 'event',
|
|
64
|
+
name: 'GameEnded',
|
|
65
|
+
anonymous: false,
|
|
66
|
+
inputs: [
|
|
67
|
+
{ name: 'user', type: 'address', indexed: true },
|
|
68
|
+
{ name: 'gameId', type: 'uint256', indexed: false },
|
|
69
|
+
{ name: 'buyIn', type: 'uint256', indexed: false },
|
|
70
|
+
{ name: 'payout', type: 'uint256', indexed: false },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const PLINKO_VRF_ABI = [
|
|
76
|
+
{
|
|
77
|
+
type: 'function',
|
|
78
|
+
name: 'getVRFFee',
|
|
79
|
+
stateMutability: 'view',
|
|
80
|
+
inputs: [{ name: 'customGasLimit', type: 'uint32' }],
|
|
81
|
+
outputs: [{ name: '', type: 'uint256' }],
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const SLOTS_VRF_ABI = [
|
|
86
|
+
{
|
|
87
|
+
type: 'function',
|
|
88
|
+
name: 'getVRFFee',
|
|
89
|
+
stateMutability: 'view',
|
|
90
|
+
inputs: [],
|
|
91
|
+
outputs: [{ name: '', type: 'uint256' }],
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
// --- Helper: Secure Wallet Loading ---
|
|
96
|
+
function getWallet() {
|
|
97
|
+
if (!fs.existsSync(WALLET_FILE)) {
|
|
98
|
+
// Return JSON error if agent tries to play before setup
|
|
99
|
+
console.error(JSON.stringify({ error: 'No wallet found. Human must run install.' }));
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
const data = JSON.parse(fs.readFileSync(WALLET_FILE, 'utf8'));
|
|
103
|
+
return privateKeyToAccount(data.privateKey);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getRpcUrl() {
|
|
107
|
+
if (process.env.APECHAIN_RPC_URL) return process.env.APECHAIN_RPC_URL;
|
|
108
|
+
if (apechain?.rpcUrls?.default?.http?.[0]) return apechain.rpcUrls.default.http[0];
|
|
109
|
+
if (apechain?.rpcUrls?.public?.http?.[0]) return apechain.rpcUrls.public.http[0];
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getTransport() {
|
|
114
|
+
if (process.env.APECHAIN_WSS_URL) {
|
|
115
|
+
return webSocket(process.env.APECHAIN_WSS_URL);
|
|
116
|
+
}
|
|
117
|
+
const rpcUrl = getRpcUrl();
|
|
118
|
+
if (!rpcUrl) {
|
|
119
|
+
console.error(JSON.stringify({ error: 'Missing APECHAIN_RPC_URL for HTTP transport.' }));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
return http(rpcUrl);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function createClients(account) {
|
|
126
|
+
const transport = getTransport();
|
|
127
|
+
const publicClient = createPublicClient({ chain: apechain, transport });
|
|
128
|
+
const walletClient = account
|
|
129
|
+
? createWalletClient({ account, chain: apechain, transport })
|
|
130
|
+
: null;
|
|
131
|
+
return { publicClient, walletClient };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function randomBytes32() {
|
|
135
|
+
return `0x${crypto.randomBytes(32).toString('hex')}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Sanitize error messages for clean JSON output (no stack traces)
|
|
139
|
+
function sanitizeError(error) {
|
|
140
|
+
if (!error) return 'Unknown error';
|
|
141
|
+
|
|
142
|
+
const msg = error.message || error.shortMessage || String(error);
|
|
143
|
+
|
|
144
|
+
// Common viem/RPC error patterns
|
|
145
|
+
if (msg.includes('could not coalesce') || msg.includes('failed to fetch')) {
|
|
146
|
+
return 'RPC connection failed. Check APECHAIN_RPC_URL or try again.';
|
|
147
|
+
}
|
|
148
|
+
if (msg.includes('insufficient funds')) {
|
|
149
|
+
return 'Insufficient funds for transaction.';
|
|
150
|
+
}
|
|
151
|
+
if (msg.includes('execution reverted')) {
|
|
152
|
+
// Try to extract revert reason
|
|
153
|
+
const match = msg.match(/execution reverted[:\s]*(.+?)(?:\n|$)/i);
|
|
154
|
+
return match ? `Transaction reverted: ${match[1].trim()}` : 'Transaction reverted by contract.';
|
|
155
|
+
}
|
|
156
|
+
if (msg.includes('user rejected') || msg.includes('denied')) {
|
|
157
|
+
return 'Transaction was rejected.';
|
|
158
|
+
}
|
|
159
|
+
if (msg.includes('nonce')) {
|
|
160
|
+
return 'Nonce error. A previous transaction may be pending.';
|
|
161
|
+
}
|
|
162
|
+
if (msg.includes('timeout') || msg.includes('timed out')) {
|
|
163
|
+
return 'Request timed out. Try again.';
|
|
164
|
+
}
|
|
165
|
+
if (msg.includes('network') || msg.includes('ENOTFOUND') || msg.includes('ECONNREFUSED')) {
|
|
166
|
+
return 'Network error. Check your connection and RPC URL.';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Remove stack traces and long technical details
|
|
170
|
+
const cleaned = msg.split('\n')[0].trim();
|
|
171
|
+
|
|
172
|
+
// Limit length
|
|
173
|
+
if (cleaned.length > 200) {
|
|
174
|
+
return cleaned.substring(0, 197) + '...';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return cleaned;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function randomUint256() {
|
|
181
|
+
return BigInt(`0x${crypto.randomBytes(32).toString('hex')}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parsePositiveInt(value, label) {
|
|
185
|
+
const parsed = Number(value);
|
|
186
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
187
|
+
console.error(JSON.stringify({ error: `${label} must be a positive integer.` }));
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
return parsed;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseNonNegativeInt(value, label) {
|
|
194
|
+
const parsed = Number(value);
|
|
195
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
196
|
+
console.error(JSON.stringify({ error: `${label} must be a non-negative integer.` }));
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
return parsed;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function ensureIntRange(value, label, min, max) {
|
|
203
|
+
const parsed = Number(value);
|
|
204
|
+
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
|
205
|
+
throw new Error(`${label} must be between ${min} and ${max}.`);
|
|
206
|
+
}
|
|
207
|
+
return parsed;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function clampRange(min, max, low, high) {
|
|
211
|
+
const boundedMin = Math.max(min, low);
|
|
212
|
+
const boundedMax = Math.min(max, high);
|
|
213
|
+
return [boundedMin, boundedMax];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function registerUsername({ account, username, persona }) {
|
|
217
|
+
const { walletClient } = createClients(account);
|
|
218
|
+
if (!walletClient) throw new Error('Wallet client unavailable.');
|
|
219
|
+
|
|
220
|
+
const siweMessage = new SiweMessage({
|
|
221
|
+
domain: SIWE_DOMAIN,
|
|
222
|
+
address: account.address,
|
|
223
|
+
statement: username,
|
|
224
|
+
uri: SIWE_URI,
|
|
225
|
+
version: '1',
|
|
226
|
+
chainId: SIWE_CHAIN_ID,
|
|
227
|
+
nonce: crypto.randomBytes(8).toString('hex'),
|
|
228
|
+
issuedAt: new Date().toISOString(),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const message = siweMessage.prepareMessage();
|
|
232
|
+
const signature = await walletClient.signMessage({ account, message });
|
|
233
|
+
|
|
234
|
+
const payload = {
|
|
235
|
+
message,
|
|
236
|
+
signature,
|
|
237
|
+
user_address: account.address,
|
|
238
|
+
username,
|
|
239
|
+
profile_picture_ipfs: null,
|
|
240
|
+
referred_by_address: ZERO_ADDRESS,
|
|
241
|
+
isAI: true,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const response = await fetch(PROFILE_API_URL, {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: { 'Content-Type': 'application/json' },
|
|
247
|
+
body: JSON.stringify(payload),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const body = await response.json().catch(() => ({}));
|
|
251
|
+
if (!response.ok) {
|
|
252
|
+
const errorMsg = body?.error || `Registration failed (${response.status}).`;
|
|
253
|
+
throw new Error(errorMsg);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const profile = saveProfile({
|
|
257
|
+
...loadProfile(),
|
|
258
|
+
username,
|
|
259
|
+
persona,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const state = loadState();
|
|
263
|
+
state.strategy = normalizeStrategy(persona);
|
|
264
|
+
saveState(state);
|
|
265
|
+
|
|
266
|
+
return { profile, response: body };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function ensureDir(dirPath) {
|
|
270
|
+
if (!fs.existsSync(dirPath)) {
|
|
271
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function loadProfile() {
|
|
276
|
+
ensureDir(APECHURCH_DIR);
|
|
277
|
+
if (!fs.existsSync(PROFILE_FILE)) {
|
|
278
|
+
const initial = {
|
|
279
|
+
version: 1,
|
|
280
|
+
persona: 'balanced',
|
|
281
|
+
username: null,
|
|
282
|
+
paused: false,
|
|
283
|
+
overrides: {},
|
|
284
|
+
createdAt: new Date().toISOString(),
|
|
285
|
+
updatedAt: new Date().toISOString(),
|
|
286
|
+
};
|
|
287
|
+
fs.writeFileSync(PROFILE_FILE, JSON.stringify(initial, null, 2));
|
|
288
|
+
return initial;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const raw = JSON.parse(fs.readFileSync(PROFILE_FILE, 'utf8'));
|
|
292
|
+
return {
|
|
293
|
+
version: 1,
|
|
294
|
+
persona: normalizeStrategy(raw.persona || 'balanced'),
|
|
295
|
+
username: raw.username || null,
|
|
296
|
+
paused: Boolean(raw.paused),
|
|
297
|
+
overrides: raw.overrides || {},
|
|
298
|
+
createdAt: raw.createdAt || new Date().toISOString(),
|
|
299
|
+
updatedAt: raw.updatedAt || new Date().toISOString(),
|
|
300
|
+
};
|
|
301
|
+
} catch (error) {
|
|
302
|
+
const fallback = {
|
|
303
|
+
version: 1,
|
|
304
|
+
persona: 'balanced',
|
|
305
|
+
username: null,
|
|
306
|
+
paused: false,
|
|
307
|
+
overrides: {},
|
|
308
|
+
createdAt: new Date().toISOString(),
|
|
309
|
+
updatedAt: new Date().toISOString(),
|
|
310
|
+
};
|
|
311
|
+
fs.writeFileSync(PROFILE_FILE, JSON.stringify(fallback, null, 2));
|
|
312
|
+
return fallback;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function saveProfile(profile) {
|
|
317
|
+
ensureDir(APECHURCH_DIR);
|
|
318
|
+
const updated = {
|
|
319
|
+
...profile,
|
|
320
|
+
persona: normalizeStrategy(profile.persona || 'balanced'),
|
|
321
|
+
paused: Boolean(profile.paused),
|
|
322
|
+
overrides: profile.overrides || {},
|
|
323
|
+
updatedAt: new Date().toISOString(),
|
|
324
|
+
};
|
|
325
|
+
fs.writeFileSync(PROFILE_FILE, JSON.stringify(updated, null, 2));
|
|
326
|
+
return updated;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function generateUsername() {
|
|
330
|
+
const suffix = crypto.randomBytes(4).toString('hex').toUpperCase();
|
|
331
|
+
return `APE_BOT_${suffix}`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function normalizeUsername(value) {
|
|
335
|
+
let name = String(value || '').trim();
|
|
336
|
+
if (!name) {
|
|
337
|
+
name = generateUsername();
|
|
338
|
+
}
|
|
339
|
+
const valid = /^[A-Za-z0-9_]+$/.test(name);
|
|
340
|
+
if (!valid) {
|
|
341
|
+
throw new Error('Username must contain only letters, numbers, and underscores.');
|
|
342
|
+
}
|
|
343
|
+
if (name.length > 32) {
|
|
344
|
+
throw new Error('Username must be 32 characters or fewer.');
|
|
345
|
+
}
|
|
346
|
+
return name;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function loadState() {
|
|
350
|
+
ensureDir(APECHURCH_DIR);
|
|
351
|
+
if (!fs.existsSync(STATE_FILE)) {
|
|
352
|
+
const initial = {
|
|
353
|
+
version: 1,
|
|
354
|
+
strategy: 'balanced',
|
|
355
|
+
lastHeartbeat: 0,
|
|
356
|
+
lastPlay: 0,
|
|
357
|
+
cooldownMs: DEFAULT_COOLDOWN_MS,
|
|
358
|
+
sessionWins: 0,
|
|
359
|
+
sessionLosses: 0,
|
|
360
|
+
consecutiveWins: 0,
|
|
361
|
+
consecutiveLosses: 0,
|
|
362
|
+
totalPnLWei: '0',
|
|
363
|
+
};
|
|
364
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(initial, null, 2));
|
|
365
|
+
return initial;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const raw = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
369
|
+
return {
|
|
370
|
+
version: 1,
|
|
371
|
+
strategy: raw.strategy || 'balanced',
|
|
372
|
+
lastHeartbeat: Number(raw.lastHeartbeat || 0),
|
|
373
|
+
lastPlay: Number(raw.lastPlay || 0),
|
|
374
|
+
cooldownMs: Number(raw.cooldownMs || DEFAULT_COOLDOWN_MS),
|
|
375
|
+
sessionWins: Number(raw.sessionWins || 0),
|
|
376
|
+
sessionLosses: Number(raw.sessionLosses || 0),
|
|
377
|
+
consecutiveWins: Number(raw.consecutiveWins || 0),
|
|
378
|
+
consecutiveLosses: Number(raw.consecutiveLosses || 0),
|
|
379
|
+
totalPnLWei: raw.totalPnLWei || '0',
|
|
380
|
+
};
|
|
381
|
+
} catch (error) {
|
|
382
|
+
const fallback = {
|
|
383
|
+
version: 1,
|
|
384
|
+
strategy: 'balanced',
|
|
385
|
+
lastHeartbeat: 0,
|
|
386
|
+
lastPlay: 0,
|
|
387
|
+
cooldownMs: DEFAULT_COOLDOWN_MS,
|
|
388
|
+
sessionWins: 0,
|
|
389
|
+
sessionLosses: 0,
|
|
390
|
+
consecutiveWins: 0,
|
|
391
|
+
consecutiveLosses: 0,
|
|
392
|
+
totalPnLWei: '0',
|
|
393
|
+
};
|
|
394
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(fallback, null, 2));
|
|
395
|
+
return fallback;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function saveState(state) {
|
|
400
|
+
ensureDir(APECHURCH_DIR);
|
|
401
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function normalizeStrategy(value) {
|
|
405
|
+
const normalized = String(value || '').toLowerCase();
|
|
406
|
+
if (['conservative', 'balanced', 'aggressive', 'degen'].includes(normalized)) {
|
|
407
|
+
return normalized;
|
|
408
|
+
}
|
|
409
|
+
return 'balanced';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function getStrategyConfig(strategy) {
|
|
413
|
+
const normalized = normalizeStrategy(strategy);
|
|
414
|
+
const defaultWeights = Object.fromEntries(
|
|
415
|
+
GAME_REGISTRY.map((game) => [game.key, 1])
|
|
416
|
+
);
|
|
417
|
+
const configs = {
|
|
418
|
+
conservative: {
|
|
419
|
+
minBetApe: 10,
|
|
420
|
+
targetBetPct: 0.05,
|
|
421
|
+
maxBetPct: 0.1,
|
|
422
|
+
baseCooldownMs: 60 * 1000, // 60 seconds
|
|
423
|
+
plinko: { mode: [0, 1], balls: [80, 100] },
|
|
424
|
+
slots: { spins: [10, 15] },
|
|
425
|
+
gameWeights: defaultWeights,
|
|
426
|
+
},
|
|
427
|
+
balanced: {
|
|
428
|
+
minBetApe: 10,
|
|
429
|
+
targetBetPct: 0.08,
|
|
430
|
+
maxBetPct: 0.15,
|
|
431
|
+
baseCooldownMs: 30 * 1000, // 30 seconds
|
|
432
|
+
plinko: { mode: [1, 2], balls: [50, 90] },
|
|
433
|
+
slots: { spins: [7, 12] },
|
|
434
|
+
gameWeights: defaultWeights,
|
|
435
|
+
},
|
|
436
|
+
aggressive: {
|
|
437
|
+
minBetApe: 10,
|
|
438
|
+
targetBetPct: 0.12,
|
|
439
|
+
maxBetPct: 0.25,
|
|
440
|
+
baseCooldownMs: 15 * 1000, // 15 seconds
|
|
441
|
+
plinko: { mode: [2, 4], balls: [20, 70] },
|
|
442
|
+
slots: { spins: [3, 10] },
|
|
443
|
+
gameWeights: defaultWeights,
|
|
444
|
+
},
|
|
445
|
+
degen: {
|
|
446
|
+
minBetApe: 10,
|
|
447
|
+
targetBetPct: 0.2,
|
|
448
|
+
maxBetPct: 0.35,
|
|
449
|
+
baseCooldownMs: 10 * 1000, // 10 seconds
|
|
450
|
+
plinko: { mode: [3, 4], balls: [10, 40] },
|
|
451
|
+
slots: { spins: [2, 6] },
|
|
452
|
+
gameWeights: defaultWeights,
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
return configs[normalized];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function normalizeWeights(baseWeights, overrideWeights) {
|
|
459
|
+
const weights = { ...baseWeights };
|
|
460
|
+
if (overrideWeights && typeof overrideWeights === 'object') {
|
|
461
|
+
for (const [key, value] of Object.entries(overrideWeights)) {
|
|
462
|
+
const parsed = Number(value);
|
|
463
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
464
|
+
weights[key] = parsed;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return weights;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function normalizeRange(range, fallback) {
|
|
472
|
+
if (!Array.isArray(range) || range.length !== 2) return fallback;
|
|
473
|
+
const min = Number(range[0]);
|
|
474
|
+
const max = Number(range[1]);
|
|
475
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) return fallback;
|
|
476
|
+
return [Math.min(min, max), Math.max(min, max)];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function applyProfileOverrides(strategyConfig, overrides) {
|
|
480
|
+
const nextConfig = { ...strategyConfig };
|
|
481
|
+
if (!overrides || typeof overrides !== 'object') return nextConfig;
|
|
482
|
+
|
|
483
|
+
if (Number.isFinite(overrides.min_bet_ape)) {
|
|
484
|
+
nextConfig.minBetApe = Math.max(Number(overrides.min_bet_ape), 0);
|
|
485
|
+
}
|
|
486
|
+
if (Number.isFinite(overrides.target_bet_pct)) {
|
|
487
|
+
nextConfig.targetBetPct = Math.max(Number(overrides.target_bet_pct), 0);
|
|
488
|
+
}
|
|
489
|
+
if (Number.isFinite(overrides.max_bet_pct)) {
|
|
490
|
+
nextConfig.maxBetPct = Math.max(Number(overrides.max_bet_pct), 0);
|
|
491
|
+
}
|
|
492
|
+
if (Number.isFinite(overrides.base_cooldown_ms)) {
|
|
493
|
+
nextConfig.baseCooldownMs = Math.max(Number(overrides.base_cooldown_ms), 0);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (overrides.game_weights) {
|
|
497
|
+
nextConfig.gameWeights = normalizeWeights(nextConfig.gameWeights, overrides.game_weights);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (overrides.plinko) {
|
|
501
|
+
nextConfig.plinko = {
|
|
502
|
+
...nextConfig.plinko,
|
|
503
|
+
mode: normalizeRange(overrides.plinko.mode, nextConfig.plinko.mode),
|
|
504
|
+
balls: normalizeRange(overrides.plinko.balls, nextConfig.plinko.balls),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (overrides.slots) {
|
|
509
|
+
nextConfig.slots = {
|
|
510
|
+
...nextConfig.slots,
|
|
511
|
+
spins: normalizeRange(overrides.slots.spins, nextConfig.slots.spins),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return nextConfig;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function randomIntInclusive(min, max) {
|
|
519
|
+
const low = Math.ceil(min);
|
|
520
|
+
const high = Math.floor(max);
|
|
521
|
+
return Math.floor(Math.random() * (high - low + 1)) + low;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function chooseWeighted(options) {
|
|
525
|
+
const total = options.reduce((sum, option) => sum + option.weight, 0);
|
|
526
|
+
const roll = Math.random() * total;
|
|
527
|
+
let acc = 0;
|
|
528
|
+
for (const option of options) {
|
|
529
|
+
acc += option.weight;
|
|
530
|
+
if (roll <= acc) return option.value;
|
|
531
|
+
}
|
|
532
|
+
return options[options.length - 1].value;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function formatApeAmount(value) {
|
|
536
|
+
return Number(value).toFixed(6);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function calculateWager(availableApe, strategyConfig) {
|
|
540
|
+
const maxAllowed = availableApe * strategyConfig.maxBetPct;
|
|
541
|
+
if (maxAllowed < strategyConfig.minBetApe) return 0;
|
|
542
|
+
const target = Math.max(strategyConfig.minBetApe, availableApe * strategyConfig.targetBetPct);
|
|
543
|
+
return Math.min(target, maxAllowed);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function selectGameAndConfig(strategyConfig) {
|
|
547
|
+
const options = GAME_REGISTRY.map((game) => ({
|
|
548
|
+
value: game.key,
|
|
549
|
+
weight: strategyConfig.gameWeights?.[game.key] ?? 1,
|
|
550
|
+
}));
|
|
551
|
+
const gameChoice = chooseWeighted(options);
|
|
552
|
+
const gameEntry = resolveGame(gameChoice);
|
|
553
|
+
if (!gameEntry) {
|
|
554
|
+
return { game: GAME_REGISTRY[0]?.key || 'jungle-plinko' };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (gameEntry.type === 'plinko') {
|
|
558
|
+
const [modeMin, modeMax] = clampRange(
|
|
559
|
+
strategyConfig.plinko.mode[0],
|
|
560
|
+
strategyConfig.plinko.mode[1],
|
|
561
|
+
gameEntry.config.mode.min,
|
|
562
|
+
gameEntry.config.mode.max
|
|
563
|
+
);
|
|
564
|
+
const [ballMin, ballMax] = clampRange(
|
|
565
|
+
strategyConfig.plinko.balls[0],
|
|
566
|
+
strategyConfig.plinko.balls[1],
|
|
567
|
+
gameEntry.config.balls.min,
|
|
568
|
+
gameEntry.config.balls.max
|
|
569
|
+
);
|
|
570
|
+
const mode = randomIntInclusive(modeMin, modeMax);
|
|
571
|
+
const balls = randomIntInclusive(ballMin, ballMax);
|
|
572
|
+
return { game: gameEntry.key, mode, balls };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (gameEntry.type === 'slots') {
|
|
576
|
+
const [spinMin, spinMax] = clampRange(
|
|
577
|
+
strategyConfig.slots.spins[0],
|
|
578
|
+
strategyConfig.slots.spins[1],
|
|
579
|
+
gameEntry.config.spins.min,
|
|
580
|
+
gameEntry.config.spins.max
|
|
581
|
+
);
|
|
582
|
+
const spins = randomIntInclusive(spinMin, spinMax);
|
|
583
|
+
return { game: gameEntry.key, spins };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return { game: gameEntry.key };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function computeCooldownMs(strategyConfig, state) {
|
|
590
|
+
const base = strategyConfig.baseCooldownMs || DEFAULT_COOLDOWN_MS;
|
|
591
|
+
if (state.consecutiveWins >= 3) return Math.max(Math.floor(base * 0.25), 60_000);
|
|
592
|
+
if (state.consecutiveWins >= 2) return Math.max(Math.floor(base * 0.5), 60_000);
|
|
593
|
+
if (state.consecutiveLosses >= 3) return Math.min(Math.floor(base * 3), 2 * 60 * 60 * 1000);
|
|
594
|
+
if (state.consecutiveLosses >= 2) return Math.min(Math.floor(base * 2), 2 * 60 * 60 * 1000);
|
|
595
|
+
return base;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function addBigIntStrings(a, b) {
|
|
599
|
+
return (BigInt(a) + BigInt(b)).toString();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function playGame({
|
|
603
|
+
account,
|
|
604
|
+
game,
|
|
605
|
+
amountApe,
|
|
606
|
+
mode,
|
|
607
|
+
balls,
|
|
608
|
+
spins,
|
|
609
|
+
timeoutMs,
|
|
610
|
+
}) {
|
|
611
|
+
const gameKey = String(game || '').toLowerCase();
|
|
612
|
+
const safeTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? timeoutMs : 0;
|
|
613
|
+
|
|
614
|
+
const gameEntry = resolveGame(gameKey);
|
|
615
|
+
if (!gameEntry) {
|
|
616
|
+
throw new Error(`Unknown game. Use: ${GAME_LIST}`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
let wager;
|
|
620
|
+
try {
|
|
621
|
+
wager = parseEther(String(amountApe));
|
|
622
|
+
} catch (error) {
|
|
623
|
+
throw new Error(`Invalid amount: ${sanitizeError(error)}`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const { publicClient, walletClient } = createClients(account);
|
|
627
|
+
const gameId = randomUint256();
|
|
628
|
+
const userRandomWord = randomBytes32();
|
|
629
|
+
|
|
630
|
+
let contractAddress = null;
|
|
631
|
+
let gameName = null;
|
|
632
|
+
let gameUrl = null;
|
|
633
|
+
let vrfFee = null;
|
|
634
|
+
let encodedData = null;
|
|
635
|
+
let config = {};
|
|
636
|
+
|
|
637
|
+
if (gameEntry.type === 'plinko') {
|
|
638
|
+
const modeValue = ensureIntRange(
|
|
639
|
+
mode ?? gameEntry.config.mode.default,
|
|
640
|
+
'mode',
|
|
641
|
+
gameEntry.config.mode.min,
|
|
642
|
+
gameEntry.config.mode.max
|
|
643
|
+
);
|
|
644
|
+
const ballsValue = ensureIntRange(
|
|
645
|
+
balls ?? gameEntry.config.balls.default,
|
|
646
|
+
'balls',
|
|
647
|
+
gameEntry.config.balls.min,
|
|
648
|
+
gameEntry.config.balls.max
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
const customGasLimit = gameEntry.vrf.baseGas + (ballsValue * gameEntry.vrf.perUnitGas);
|
|
652
|
+
try {
|
|
653
|
+
vrfFee = await publicClient.readContract({
|
|
654
|
+
address: gameEntry.contract,
|
|
655
|
+
abi: PLINKO_VRF_ABI,
|
|
656
|
+
functionName: 'getVRFFee',
|
|
657
|
+
args: [customGasLimit],
|
|
658
|
+
});
|
|
659
|
+
} catch (error) {
|
|
660
|
+
throw new Error(`Failed to read VRF fee (plinko): ${sanitizeError(error)}`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
encodedData = encodeAbiParameters(
|
|
664
|
+
[
|
|
665
|
+
{ name: 'gameMode', type: 'uint8' },
|
|
666
|
+
{ name: 'numBalls', type: 'uint8' },
|
|
667
|
+
{ name: 'gameId', type: 'uint256' },
|
|
668
|
+
{ name: 'ref', type: 'address' },
|
|
669
|
+
{ name: 'userRandomWord', type: 'bytes32' },
|
|
670
|
+
],
|
|
671
|
+
[modeValue, ballsValue, gameId, ZERO_ADDRESS, userRandomWord]
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
contractAddress = gameEntry.contract;
|
|
675
|
+
gameName = gameEntry.key;
|
|
676
|
+
gameUrl = `https://www.ape.church/games/${gameEntry.slug}?id=${gameId.toString()}`;
|
|
677
|
+
config = { mode: modeValue, balls: ballsValue };
|
|
678
|
+
} else if (gameEntry.type === 'slots') {
|
|
679
|
+
const spinsValue = ensureIntRange(
|
|
680
|
+
spins ?? gameEntry.config.spins.default,
|
|
681
|
+
'spins',
|
|
682
|
+
gameEntry.config.spins.min,
|
|
683
|
+
gameEntry.config.spins.max
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
try {
|
|
687
|
+
vrfFee = await publicClient.readContract({
|
|
688
|
+
address: gameEntry.contract,
|
|
689
|
+
abi: SLOTS_VRF_ABI,
|
|
690
|
+
functionName: 'getVRFFee',
|
|
691
|
+
});
|
|
692
|
+
} catch (error) {
|
|
693
|
+
throw new Error(`Failed to read VRF fee (slots): ${sanitizeError(error)}`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
encodedData = encodeAbiParameters(
|
|
697
|
+
[
|
|
698
|
+
{ name: 'gameId', type: 'uint256' },
|
|
699
|
+
{ name: 'numSpins', type: 'uint8' },
|
|
700
|
+
{ name: 'ref', type: 'address' },
|
|
701
|
+
{ name: 'userRandomWord', type: 'bytes32' },
|
|
702
|
+
],
|
|
703
|
+
[gameId, spinsValue, ZERO_ADDRESS, userRandomWord]
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
contractAddress = gameEntry.contract;
|
|
707
|
+
gameName = gameEntry.key;
|
|
708
|
+
gameUrl = `https://www.ape.church/games/${gameEntry.slug}?id=${gameId.toString()}`;
|
|
709
|
+
config = { spins: spinsValue };
|
|
710
|
+
} else {
|
|
711
|
+
throw new Error(`Unsupported game type: ${gameEntry.type}`);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const totalValue = wager + vrfFee;
|
|
715
|
+
|
|
716
|
+
let resolveEvent;
|
|
717
|
+
let rejectEvent;
|
|
718
|
+
const eventPromise = new Promise((resolve, reject) => {
|
|
719
|
+
resolveEvent = resolve;
|
|
720
|
+
rejectEvent = reject;
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
const unwatch = publicClient.watchContractEvent({
|
|
724
|
+
address: contractAddress,
|
|
725
|
+
abi: GAME_CONTRACT_ABI,
|
|
726
|
+
eventName: 'GameEnded',
|
|
727
|
+
args: { user: account.address },
|
|
728
|
+
onLogs: (logs) => {
|
|
729
|
+
for (const log of logs) {
|
|
730
|
+
if (log?.args?.gameId === gameId) {
|
|
731
|
+
resolveEvent(log.args);
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
},
|
|
736
|
+
onError: (error) => rejectEvent(error),
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
let timeoutId = null;
|
|
740
|
+
if (safeTimeoutMs > 0) {
|
|
741
|
+
timeoutId = setTimeout(() => {
|
|
742
|
+
resolveEvent(null);
|
|
743
|
+
}, safeTimeoutMs);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
let txHash;
|
|
747
|
+
try {
|
|
748
|
+
txHash = await walletClient.writeContract({
|
|
749
|
+
address: contractAddress,
|
|
750
|
+
abi: GAME_CONTRACT_ABI,
|
|
751
|
+
functionName: 'play',
|
|
752
|
+
args: [account.address, encodedData],
|
|
753
|
+
value: totalValue,
|
|
754
|
+
});
|
|
755
|
+
} catch (error) {
|
|
756
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
757
|
+
unwatch();
|
|
758
|
+
throw new Error(`Transaction failed: ${sanitizeError(error)}`);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
let eventResult = null;
|
|
762
|
+
try {
|
|
763
|
+
eventResult = await eventPromise;
|
|
764
|
+
} catch (error) {
|
|
765
|
+
eventResult = null;
|
|
766
|
+
} finally {
|
|
767
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
768
|
+
unwatch();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
status: eventResult ? 'complete' : 'pending',
|
|
773
|
+
action: 'bet',
|
|
774
|
+
game: gameName,
|
|
775
|
+
contract: contractAddress,
|
|
776
|
+
tx: txHash,
|
|
777
|
+
gameId: gameId.toString(),
|
|
778
|
+
game_url: gameUrl,
|
|
779
|
+
config,
|
|
780
|
+
wager_wei: wager.toString(),
|
|
781
|
+
wager_ape: formatEther(wager),
|
|
782
|
+
vrf_fee_wei: vrfFee.toString(),
|
|
783
|
+
vrf_fee_ape: formatEther(vrfFee),
|
|
784
|
+
total_value_wei: totalValue.toString(),
|
|
785
|
+
total_value_ape: formatEther(totalValue),
|
|
786
|
+
result: eventResult
|
|
787
|
+
? {
|
|
788
|
+
user: eventResult.user,
|
|
789
|
+
buy_in_wei: eventResult.buyIn.toString(),
|
|
790
|
+
buy_in_ape: formatEther(eventResult.buyIn),
|
|
791
|
+
payout_wei: eventResult.payout.toString(),
|
|
792
|
+
payout_ape: formatEther(eventResult.payout),
|
|
793
|
+
}
|
|
794
|
+
: null,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// --- COMMAND: INSTALL (The Human Experience) ---
|
|
799
|
+
program
|
|
800
|
+
.command('install')
|
|
801
|
+
.description('Setup the Ape Church Agent')
|
|
802
|
+
.option('--username <name>', 'Username (must end with _CLAWBOT)')
|
|
803
|
+
.option('--persona <name>', 'conservative | balanced | aggressive | degen')
|
|
804
|
+
.action(async (opts) => {
|
|
805
|
+
console.log('Initializing Ape Church Protocol...');
|
|
806
|
+
|
|
807
|
+
// 1. Generate Wallet (Self-Sovereign)
|
|
808
|
+
let address;
|
|
809
|
+
if (fs.existsSync(WALLET_FILE)) {
|
|
810
|
+
const data = JSON.parse(fs.readFileSync(WALLET_FILE));
|
|
811
|
+
address = privateKeyToAccount(data.privateKey).address;
|
|
812
|
+
console.log('Wallet already exists.');
|
|
813
|
+
} else {
|
|
814
|
+
const pk = generatePrivateKey();
|
|
815
|
+
const account = privateKeyToAccount(pk);
|
|
816
|
+
fs.writeFileSync(WALLET_FILE, JSON.stringify({ privateKey: pk }));
|
|
817
|
+
address = account.address;
|
|
818
|
+
console.log('Generated new Agent Wallet.');
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// 2. Inject the Brain (SKILL.md)
|
|
822
|
+
if (!fs.existsSync(SKILL_TARGET_DIR)) {
|
|
823
|
+
fs.mkdirSync(SKILL_TARGET_DIR, { recursive: true });
|
|
824
|
+
}
|
|
825
|
+
// Robust path finding for ESM modules
|
|
826
|
+
const assetsDir = path.join(path.dirname(new URL(import.meta.url).pathname), '../assets');
|
|
827
|
+
const assetFiles = ['SKILL.md', 'HEARTBEAT.md', 'STRATEGY.md', 'skill.json'];
|
|
828
|
+
for (const file of assetFiles) {
|
|
829
|
+
const source = path.join(assetsDir, file);
|
|
830
|
+
if (fs.existsSync(source)) {
|
|
831
|
+
fs.copyFileSync(source, path.join(SKILL_TARGET_DIR, file));
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
console.log('Injected skill files into Agent brain.');
|
|
835
|
+
|
|
836
|
+
// 3. Register username (optional, auto if missing)
|
|
837
|
+
const localProfile = loadProfile();
|
|
838
|
+
const persona = normalizeStrategy(opts.persona || localProfile.persona || 'balanced');
|
|
839
|
+
let username;
|
|
840
|
+
try {
|
|
841
|
+
username = normalizeUsername(opts.username || localProfile.username || '');
|
|
842
|
+
} catch (error) {
|
|
843
|
+
console.error(`Invalid username provided; generating one. Reason: ${error.message}`);
|
|
844
|
+
username = normalizeUsername('');
|
|
845
|
+
}
|
|
846
|
+
saveProfile({ ...localProfile, persona, username });
|
|
847
|
+
const state = loadState();
|
|
848
|
+
state.strategy = persona;
|
|
849
|
+
saveState(state);
|
|
850
|
+
|
|
851
|
+
const usernameWasProvided = opts.username && opts.username.trim().length > 0;
|
|
852
|
+
console.log(`Username: ${username}${usernameWasProvided ? '' : ' (auto-generated)'}`);
|
|
853
|
+
try {
|
|
854
|
+
await registerUsername({ account: getWallet(), username, persona });
|
|
855
|
+
console.log('Username registered via SIWE.');
|
|
856
|
+
} catch (error) {
|
|
857
|
+
console.error(`Username registration failed: ${error.message}`);
|
|
858
|
+
console.log('You can retry later with: apechurch register --username <NAME>');
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// 4. The Handshake (Replacing the Claim Link)
|
|
862
|
+
console.log('\nSETUP COMPLETE');
|
|
863
|
+
console.log('---------------------------------------');
|
|
864
|
+
console.log(`AGENT ADDRESS: ${address}`);
|
|
865
|
+
console.log(`USERNAME: ${username}`);
|
|
866
|
+
if (!usernameWasProvided) {
|
|
867
|
+
console.log(' (Change anytime: apechurch register --username <YOUR_NAME>)');
|
|
868
|
+
}
|
|
869
|
+
console.log(`PERSONA: ${persona}`);
|
|
870
|
+
console.log('');
|
|
871
|
+
console.log('ACTION REQUIRED: Send APE (ApeChain) to this address.');
|
|
872
|
+
console.log('FUNDING GUIDE:');
|
|
873
|
+
console.log(
|
|
874
|
+
'1) Open https://relay.link/bridge/apechain?toCurrency=0x0000000000000000000000000000000000000000'
|
|
875
|
+
);
|
|
876
|
+
console.log('2) Connect your wallet.');
|
|
877
|
+
console.log('3) Paste the agent address in ApeChain buy area:');
|
|
878
|
+
console.log(' Select wallet -> Paste wallet address');
|
|
879
|
+
console.log('The Agent will wake up automatically once funded.');
|
|
880
|
+
console.log('---------------------------------------');
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
// --- COMMAND: STATUS (The Agent Experience) ---
|
|
884
|
+
program
|
|
885
|
+
.command('status')
|
|
886
|
+
.option('--json', 'Output JSON for the Agent')
|
|
887
|
+
.action(async (opts) => {
|
|
888
|
+
const account = getWallet();
|
|
889
|
+
const profile = loadProfile();
|
|
890
|
+
const { publicClient } = createClients();
|
|
891
|
+
|
|
892
|
+
let balance;
|
|
893
|
+
try {
|
|
894
|
+
balance = await publicClient.getBalance({ address: account.address });
|
|
895
|
+
} catch (error) {
|
|
896
|
+
console.error(JSON.stringify({ error: `Failed to fetch balance: ${sanitizeError(error)}` }));
|
|
897
|
+
process.exit(1);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const formattedBalance = parseFloat(formatEther(balance));
|
|
901
|
+
|
|
902
|
+
const availableApe = Math.max(formattedBalance - GAS_RESERVE_APE, 0);
|
|
903
|
+
|
|
904
|
+
const data = {
|
|
905
|
+
address: account.address,
|
|
906
|
+
balance: formattedBalance.toFixed(4),
|
|
907
|
+
available_ape: availableApe.toFixed(4),
|
|
908
|
+
gas_reserve_ape: GAS_RESERVE_APE.toFixed(4),
|
|
909
|
+
paused: profile.paused,
|
|
910
|
+
persona: profile.persona,
|
|
911
|
+
username: profile.username,
|
|
912
|
+
can_play: availableApe > 0.005 && !profile.paused,
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
if (opts.json) console.log(JSON.stringify(data));
|
|
916
|
+
else {
|
|
917
|
+
console.log(`Address: ${data.address}`);
|
|
918
|
+
console.log(`Username: ${data.username || '(not set)'}`);
|
|
919
|
+
console.log(`Persona: ${data.persona}`);
|
|
920
|
+
console.log(`Balance: ${data.balance} APE`);
|
|
921
|
+
console.log(`Available to wager: ${data.available_ape} APE (1 APE reserved)`);
|
|
922
|
+
console.log(`Paused: ${data.paused ? 'YES' : 'No'}`);
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
// --- COMMAND: PROFILE ---
|
|
927
|
+
const profileCommand = program.command('profile').description('Manage agent profile');
|
|
928
|
+
|
|
929
|
+
profileCommand
|
|
930
|
+
.command('show')
|
|
931
|
+
.option('--json', 'Output JSON only')
|
|
932
|
+
.action((opts) => {
|
|
933
|
+
const profile = loadProfile();
|
|
934
|
+
if (opts.json) console.log(JSON.stringify(profile));
|
|
935
|
+
else console.log(JSON.stringify(profile, null, 2));
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
profileCommand
|
|
939
|
+
.command('set')
|
|
940
|
+
.option('--persona <name>', 'conservative | balanced | aggressive | degen')
|
|
941
|
+
.option('--username <name>', 'Set local username (must end with _CLAWBOT)')
|
|
942
|
+
.option('--json', 'Output JSON only')
|
|
943
|
+
.action((opts) => {
|
|
944
|
+
const profile = loadProfile();
|
|
945
|
+
if (opts.persona) profile.persona = normalizeStrategy(opts.persona);
|
|
946
|
+
if (opts.username) profile.username = normalizeUsername(opts.username);
|
|
947
|
+
const updated = saveProfile(profile);
|
|
948
|
+
const state = loadState();
|
|
949
|
+
state.strategy = normalizeStrategy(updated.persona);
|
|
950
|
+
saveState(state);
|
|
951
|
+
if (opts.json) console.log(JSON.stringify(updated));
|
|
952
|
+
else console.log(JSON.stringify(updated, null, 2));
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
// --- COMMAND: REGISTER ---
|
|
956
|
+
program
|
|
957
|
+
.command('register')
|
|
958
|
+
.option('--username <name>', 'Username (must end with _CLAWBOT)')
|
|
959
|
+
.option('--persona <name>', 'conservative | balanced | aggressive | degen')
|
|
960
|
+
.option('--json', 'Output JSON only')
|
|
961
|
+
.action(async (opts) => {
|
|
962
|
+
const account = getWallet();
|
|
963
|
+
const profile = loadProfile();
|
|
964
|
+
const persona = normalizeStrategy(opts.persona || profile.persona || 'balanced');
|
|
965
|
+
|
|
966
|
+
let username;
|
|
967
|
+
try {
|
|
968
|
+
username = normalizeUsername(opts.username || profile.username || '');
|
|
969
|
+
} catch (error) {
|
|
970
|
+
console.error(JSON.stringify({ error: error.message }));
|
|
971
|
+
process.exit(1);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
try {
|
|
975
|
+
const result = await registerUsername({ account, username, persona });
|
|
976
|
+
const response = {
|
|
977
|
+
status: 'registered',
|
|
978
|
+
username,
|
|
979
|
+
address: account.address,
|
|
980
|
+
persona,
|
|
981
|
+
api_url: PROFILE_API_URL,
|
|
982
|
+
server: result.response,
|
|
983
|
+
};
|
|
984
|
+
if (opts.json) console.log(JSON.stringify(response));
|
|
985
|
+
else console.log(JSON.stringify(response, null, 2));
|
|
986
|
+
} catch (error) {
|
|
987
|
+
console.error(JSON.stringify({ error: error.message }));
|
|
988
|
+
process.exit(1);
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
// --- COMMAND: PAUSE (Stop autonomous play) ---
|
|
993
|
+
program
|
|
994
|
+
.command('pause')
|
|
995
|
+
.description('Pause autonomous play (heartbeat will skip)')
|
|
996
|
+
.option('--json', 'Output JSON only')
|
|
997
|
+
.action((opts) => {
|
|
998
|
+
const profile = loadProfile();
|
|
999
|
+
profile.paused = true;
|
|
1000
|
+
const updated = saveProfile(profile);
|
|
1001
|
+
const response = {
|
|
1002
|
+
status: 'paused',
|
|
1003
|
+
message: 'Autonomous play paused. Run `apechurch resume` to continue.',
|
|
1004
|
+
paused: true,
|
|
1005
|
+
updatedAt: updated.updatedAt,
|
|
1006
|
+
};
|
|
1007
|
+
if (opts.json) console.log(JSON.stringify(response));
|
|
1008
|
+
else console.log(JSON.stringify(response, null, 2));
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
// --- COMMAND: RESUME (Resume autonomous play) ---
|
|
1012
|
+
program
|
|
1013
|
+
.command('resume')
|
|
1014
|
+
.description('Resume autonomous play')
|
|
1015
|
+
.option('--json', 'Output JSON only')
|
|
1016
|
+
.action((opts) => {
|
|
1017
|
+
const profile = loadProfile();
|
|
1018
|
+
profile.paused = false;
|
|
1019
|
+
const updated = saveProfile(profile);
|
|
1020
|
+
const response = {
|
|
1021
|
+
status: 'resumed',
|
|
1022
|
+
message: 'Autonomous play resumed. Heartbeat will play on next run.',
|
|
1023
|
+
paused: false,
|
|
1024
|
+
updatedAt: updated.updatedAt,
|
|
1025
|
+
};
|
|
1026
|
+
if (opts.json) console.log(JSON.stringify(response));
|
|
1027
|
+
else console.log(JSON.stringify(response, null, 2));
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// --- COMMAND: BET (The Agent Action) ---
|
|
1031
|
+
program
|
|
1032
|
+
.command('bet')
|
|
1033
|
+
.requiredOption('--game <type>', GAME_LIST)
|
|
1034
|
+
.requiredOption('--amount <ape>', 'Wager amount')
|
|
1035
|
+
.option('--mode <0-4>', 'Game mode (0-4). Higher is riskier.', '0')
|
|
1036
|
+
.option('--balls <1-100>', 'Number of balls to drop (1-100).', '50')
|
|
1037
|
+
.option('--spins <1-15>', 'Number of spins for slots (1-15).', '10')
|
|
1038
|
+
.option('--timeout <ms>', 'Max ms to wait for GameEnded event. Use 0 to wait indefinitely.', '0')
|
|
1039
|
+
.action(async (opts) => {
|
|
1040
|
+
const account = getWallet();
|
|
1041
|
+
const { publicClient } = createClients();
|
|
1042
|
+
|
|
1043
|
+
// Safety check: ensure we have more than gas reserve
|
|
1044
|
+
let balance;
|
|
1045
|
+
try {
|
|
1046
|
+
balance = await publicClient.getBalance({ address: account.address });
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
console.error(JSON.stringify({ error: `Failed to fetch balance: ${sanitizeError(error)}` }));
|
|
1049
|
+
process.exit(1);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const balanceApe = parseFloat(formatEther(balance));
|
|
1053
|
+
const availableApe = Math.max(balanceApe - GAS_RESERVE_APE, 0);
|
|
1054
|
+
|
|
1055
|
+
if (availableApe <= 0) {
|
|
1056
|
+
console.log(JSON.stringify({
|
|
1057
|
+
status: 'skipped',
|
|
1058
|
+
reason: 'insufficient_balance',
|
|
1059
|
+
balance_ape: balanceApe.toFixed(6),
|
|
1060
|
+
available_ape: '0.000000',
|
|
1061
|
+
gas_reserve_ape: GAS_RESERVE_APE.toFixed(6),
|
|
1062
|
+
message: 'Balance at or below gas reserve. Cannot play.',
|
|
1063
|
+
}));
|
|
1064
|
+
return; // Don't exit with error, just return gracefully
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const timeoutMs = parseNonNegativeInt(opts.timeout, 'timeout');
|
|
1068
|
+
try {
|
|
1069
|
+
const response = await playGame({
|
|
1070
|
+
account,
|
|
1071
|
+
game: opts.game,
|
|
1072
|
+
amountApe: opts.amount,
|
|
1073
|
+
mode: opts.mode,
|
|
1074
|
+
balls: opts.balls,
|
|
1075
|
+
spins: opts.spins,
|
|
1076
|
+
timeoutMs,
|
|
1077
|
+
});
|
|
1078
|
+
console.log(JSON.stringify(response));
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
console.error(JSON.stringify({ error: error.message }));
|
|
1081
|
+
process.exit(1);
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
// --- COMMAND: HEARTBEAT (Autonomous Loop) ---
|
|
1086
|
+
program
|
|
1087
|
+
.command('heartbeat')
|
|
1088
|
+
.option('--strategy <name>', 'conservative | balanced | aggressive')
|
|
1089
|
+
.option('--cooldown <ms>', 'Minimum ms between plays (0 = use strategy cooldown)', '0')
|
|
1090
|
+
.option('--timeout <ms>', 'Max ms to wait for GameEnded event. Use 0 to wait indefinitely.', '0')
|
|
1091
|
+
.option('--json', 'Output JSON only')
|
|
1092
|
+
.action(async (opts) => {
|
|
1093
|
+
const account = getWallet();
|
|
1094
|
+
const state = loadState();
|
|
1095
|
+
const profile = loadProfile();
|
|
1096
|
+
const now = Date.now();
|
|
1097
|
+
|
|
1098
|
+
// Check if paused - skip gracefully without fetching balance
|
|
1099
|
+
if (profile.paused) {
|
|
1100
|
+
state.lastHeartbeat = now;
|
|
1101
|
+
saveState(state);
|
|
1102
|
+
const response = {
|
|
1103
|
+
action: 'heartbeat',
|
|
1104
|
+
status: 'skipped',
|
|
1105
|
+
reason: 'paused',
|
|
1106
|
+
message: 'Autonomous play is paused. Run `apechurch resume` to continue.',
|
|
1107
|
+
address: account.address,
|
|
1108
|
+
paused: true,
|
|
1109
|
+
};
|
|
1110
|
+
if (opts.json) console.log(JSON.stringify(response));
|
|
1111
|
+
else console.log(JSON.stringify(response, null, 2));
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
state.lastHeartbeat = now;
|
|
1116
|
+
if (opts.strategy) state.strategy = normalizeStrategy(opts.strategy);
|
|
1117
|
+
else if (profile.persona) state.strategy = normalizeStrategy(profile.persona);
|
|
1118
|
+
const requestedCooldown = parseNonNegativeInt(opts.cooldown, 'cooldown');
|
|
1119
|
+
if (requestedCooldown > 0) state.cooldownMs = requestedCooldown;
|
|
1120
|
+
const timeoutMs = parseNonNegativeInt(opts.timeout, 'timeout');
|
|
1121
|
+
|
|
1122
|
+
const { publicClient } = createClients();
|
|
1123
|
+
let balance;
|
|
1124
|
+
try {
|
|
1125
|
+
balance = await publicClient.getBalance({ address: account.address });
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
console.error(JSON.stringify({ error: `Failed to fetch balance: ${sanitizeError(error)}` }));
|
|
1128
|
+
process.exit(1);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const balanceApe = parseFloat(formatEther(balance));
|
|
1132
|
+
const availableApe = Math.max(balanceApe - GAS_RESERVE_APE, 0);
|
|
1133
|
+
const strategy = normalizeStrategy(state.strategy);
|
|
1134
|
+
const strategyConfig = applyProfileOverrides(
|
|
1135
|
+
getStrategyConfig(strategy),
|
|
1136
|
+
profile.overrides
|
|
1137
|
+
);
|
|
1138
|
+
const dynamicCooldownMs = computeCooldownMs(strategyConfig, state);
|
|
1139
|
+
const cooldownMs =
|
|
1140
|
+
requestedCooldown > 0 ? requestedCooldown : dynamicCooldownMs;
|
|
1141
|
+
|
|
1142
|
+
const baseResponse = {
|
|
1143
|
+
action: 'heartbeat',
|
|
1144
|
+
strategy,
|
|
1145
|
+
address: account.address,
|
|
1146
|
+
balance_ape: balanceApe.toFixed(6),
|
|
1147
|
+
available_ape: availableApe.toFixed(6),
|
|
1148
|
+
gas_reserve_ape: GAS_RESERVE_APE.toFixed(6),
|
|
1149
|
+
paused: false,
|
|
1150
|
+
last_play: state.lastPlay,
|
|
1151
|
+
cooldown_ms: cooldownMs,
|
|
1152
|
+
consecutive_wins: state.consecutiveWins,
|
|
1153
|
+
consecutive_losses: state.consecutiveLosses,
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
if (availableApe <= 0 || availableApe < strategyConfig.minBetApe) {
|
|
1157
|
+
saveState(state);
|
|
1158
|
+
const response = {
|
|
1159
|
+
...baseResponse,
|
|
1160
|
+
status: 'skipped',
|
|
1161
|
+
reason: 'insufficient_available_ape',
|
|
1162
|
+
};
|
|
1163
|
+
if (opts.json) console.log(JSON.stringify(response));
|
|
1164
|
+
else console.log(JSON.stringify(response, null, 2));
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
if (state.lastPlay && cooldownMs > 0 && now - state.lastPlay < cooldownMs) {
|
|
1169
|
+
saveState(state);
|
|
1170
|
+
const response = {
|
|
1171
|
+
...baseResponse,
|
|
1172
|
+
status: 'skipped',
|
|
1173
|
+
reason: 'cooldown',
|
|
1174
|
+
next_play_after_ms: Math.max(cooldownMs - (now - state.lastPlay), 0),
|
|
1175
|
+
};
|
|
1176
|
+
if (opts.json) console.log(JSON.stringify(response));
|
|
1177
|
+
else console.log(JSON.stringify(response, null, 2));
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const wagerApe = calculateWager(availableApe, strategyConfig);
|
|
1182
|
+
if (wagerApe < strategyConfig.minBetApe) {
|
|
1183
|
+
saveState(state);
|
|
1184
|
+
const response = {
|
|
1185
|
+
...baseResponse,
|
|
1186
|
+
status: 'skipped',
|
|
1187
|
+
reason: 'wager_below_minimum',
|
|
1188
|
+
wager_ape: formatApeAmount(wagerApe),
|
|
1189
|
+
};
|
|
1190
|
+
if (opts.json) console.log(JSON.stringify(response));
|
|
1191
|
+
else console.log(JSON.stringify(response, null, 2));
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const selection = selectGameAndConfig(strategyConfig);
|
|
1196
|
+
const wagerApeString = formatApeAmount(wagerApe);
|
|
1197
|
+
|
|
1198
|
+
try {
|
|
1199
|
+
const playResponse = await playGame({
|
|
1200
|
+
account,
|
|
1201
|
+
game: selection.game,
|
|
1202
|
+
amountApe: wagerApeString,
|
|
1203
|
+
mode: selection.mode,
|
|
1204
|
+
balls: selection.balls,
|
|
1205
|
+
spins: selection.spins,
|
|
1206
|
+
timeoutMs,
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
state.lastPlay = now;
|
|
1210
|
+
if (playResponse?.result) {
|
|
1211
|
+
const pnlWei = (BigInt(playResponse.result.payout_wei) -
|
|
1212
|
+
BigInt(playResponse.result.buy_in_wei)).toString();
|
|
1213
|
+
state.totalPnLWei = addBigIntStrings(state.totalPnLWei, pnlWei);
|
|
1214
|
+
if (BigInt(pnlWei) >= 0n) {
|
|
1215
|
+
state.sessionWins += 1;
|
|
1216
|
+
state.consecutiveWins += 1;
|
|
1217
|
+
state.consecutiveLosses = 0;
|
|
1218
|
+
} else {
|
|
1219
|
+
state.sessionLosses += 1;
|
|
1220
|
+
state.consecutiveLosses += 1;
|
|
1221
|
+
state.consecutiveWins = 0;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
saveState(state);
|
|
1226
|
+
const response = {
|
|
1227
|
+
...baseResponse,
|
|
1228
|
+
status: playResponse.status,
|
|
1229
|
+
wager_ape: wagerApeString,
|
|
1230
|
+
game: playResponse.game,
|
|
1231
|
+
config: playResponse.config,
|
|
1232
|
+
tx: playResponse.tx,
|
|
1233
|
+
gameId: playResponse.gameId,
|
|
1234
|
+
game_url: playResponse.game_url,
|
|
1235
|
+
result: playResponse.result,
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
if (opts.json) console.log(JSON.stringify(response));
|
|
1239
|
+
else console.log(JSON.stringify(response, null, 2));
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
saveState(state);
|
|
1242
|
+
console.error(JSON.stringify({ error: error.message }));
|
|
1243
|
+
process.exit(1);
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
program.parse(process.argv);
|