@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/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);