dankgrinder 7.6.0 → 7.7.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/lib/commands/farm.js +218 -9
- package/lib/commands/farmVision.js +190 -96
- package/lib/commands/utils.js +17 -3
- package/lib/grinder.js +65 -16
- package/lib/rawLogger.js +23 -2
- package/package.json +1 -1
package/lib/commands/farm.js
CHANGED
|
@@ -4,6 +4,7 @@ const {
|
|
|
4
4
|
isCV2, ensureCV2, stripAnsi, needsItem, clickCV2SelectMenu,
|
|
5
5
|
} = require('./utils');
|
|
6
6
|
const { buyItem, buyItemsBatch } = require('./shop');
|
|
7
|
+
const rawLogger = require('../../lib/rawLogger');
|
|
7
8
|
const {
|
|
8
9
|
downloadImage,
|
|
9
10
|
extractFarmImageUrl,
|
|
@@ -938,6 +939,17 @@ function pickFarmActionButton(msg, text) {
|
|
|
938
939
|
return null;
|
|
939
940
|
}
|
|
940
941
|
|
|
942
|
+
// Captures the ephemeral interaction response content from a CV2 ack object.
|
|
943
|
+
// Dank Memer sends error/success text in the interaction ACK (type 4/7) content field.
|
|
944
|
+
function captureCvvAckContent(ack) {
|
|
945
|
+
if (!ack) return '';
|
|
946
|
+
const flags = ack?.flags ?? ack?.data?.flags ?? 0;
|
|
947
|
+
const content = String(stripAnsi(ack?.content || ack?.data?.content || '')).trim();
|
|
948
|
+
if (!content) return '';
|
|
949
|
+
const isEphemeral = (flags & 64) !== 0;
|
|
950
|
+
return `[ephemeral=${isEphemeral}] ${content}`;
|
|
951
|
+
}
|
|
952
|
+
|
|
941
953
|
async function clickAndCapture({ channel, waitForDankMemer, response, button, tag, timeoutMs = 9000 }) {
|
|
942
954
|
const baseline = brief(getFullText(response), 400);
|
|
943
955
|
const clickRes = await safeClickButton(response, button);
|
|
@@ -947,7 +959,30 @@ async function clickAndCapture({ channel, waitForDankMemer, response, button, ta
|
|
|
947
959
|
delete response._lastInteractionAck;
|
|
948
960
|
}
|
|
949
961
|
|
|
950
|
-
|
|
962
|
+
// CV2 HTTP fallback returns the interaction ACK (not the updated message).
|
|
963
|
+
// If clickRes looks like an ack rather than a message, extract it and wait for
|
|
964
|
+
// the edited message instead.
|
|
965
|
+
const isAckObject = clickRes && typeof clickRes === 'object' && !clickRes.id && (clickRes.interactionStatus || clickRes.interactionType);
|
|
966
|
+
const capturedAck = isAckObject ? clickRes : null;
|
|
967
|
+
|
|
968
|
+
// Register a one-time callback to capture ephemeral responses (e.g. "You harvested: Nothing")
|
|
969
|
+
// from the raw gateway packet — the only place where ephemeral embeds/CV2 text are preserved.
|
|
970
|
+
let capturedEphemeral = null;
|
|
971
|
+
rawLogger.onNextEphemeral((parsed) => {
|
|
972
|
+
capturedEphemeral = parsed;
|
|
973
|
+
const cv2t = parsed.cv2Text || '';
|
|
974
|
+
const allt = parsed.allText || '';
|
|
975
|
+
const txt = cv2t || allt;
|
|
976
|
+
if (txt.includes('harvested')) LOG.info(`${tag}-ephemeral harvest: ${txt.slice(0, 200)}`);
|
|
977
|
+
else LOG.info(`${tag}-ephemeral ${txt.slice(0, 200)}`);
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
// Extra grace period: the ephemeral often arrives after safeClickButton resolves
|
|
981
|
+
// (the library may return the stale message immediately, before the gateway delivers the
|
|
982
|
+
// ephemeral). This ensures we capture it even when it arrives late.
|
|
983
|
+
await sleep(1500);
|
|
984
|
+
|
|
985
|
+
let post = (!isAckObject && clickRes) ? clickRes : null;
|
|
951
986
|
if (!post && response.id) post = await waitForEditedMessage(channel, response.id, baseline, timeoutMs);
|
|
952
987
|
if (!post) post = await waitForDankMemer(timeoutMs);
|
|
953
988
|
|
|
@@ -956,17 +991,23 @@ async function clickAndCapture({ channel, waitForDankMemer, response, button, ta
|
|
|
956
991
|
|
|
957
992
|
// Capture any additional immediate callback message (often ephemeral-like
|
|
958
993
|
// "only you can see this" notices) that may be separate from edited CV2 post.
|
|
994
|
+
let extraMsg = null;
|
|
959
995
|
try {
|
|
960
996
|
const side = await waitForDankMemer(1500);
|
|
961
997
|
if (side && side.id !== post.id) {
|
|
962
998
|
logEphemeralLike(`${tag}-post-extra`, side);
|
|
963
|
-
|
|
999
|
+
extraMsg = side;
|
|
964
1000
|
}
|
|
965
1001
|
} catch {}
|
|
966
1002
|
|
|
967
1003
|
if (isCV2(post)) await ensureCV2(post);
|
|
968
1004
|
logMsg(post, tag);
|
|
969
1005
|
logFarmState(tag, post);
|
|
1006
|
+
|
|
1007
|
+
// Attach captured ack and extra message so callers can access them.
|
|
1008
|
+
post._capturedAck = capturedAck;
|
|
1009
|
+
post._farmExtraMsg = extraMsg;
|
|
1010
|
+
post._capturedEphemeral = capturedEphemeral;
|
|
970
1011
|
return post;
|
|
971
1012
|
}
|
|
972
1013
|
|
|
@@ -1172,11 +1213,20 @@ async function advancePastConfirmation(response, waitForDankMemer) {
|
|
|
1172
1213
|
if (!response) return null;
|
|
1173
1214
|
|
|
1174
1215
|
const CONFIRM_WORDS = ['continue', 'confirm', 'confirm all', 'done', 'back to farm', 'back', 'close'];
|
|
1216
|
+
// Also treat "All" buttons as confirmation actions (e.g. "Hoe All", "Water All").
|
|
1217
|
+
// Skip the plain "back" word to avoid matching the Back arrow button; prefer explicit
|
|
1218
|
+
// confirm words first, then fall back to "All" buttons as confirmation.
|
|
1219
|
+
const ALL_AS_CONFIRM = ['hoe all', 'water all', 'plant all', 'harvest all', 'fertilize all'];
|
|
1175
1220
|
const MAX_PAGES = 3;
|
|
1176
1221
|
|
|
1177
1222
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
1178
1223
|
const btns = getAllButtons(response).filter(b => !b.disabled);
|
|
1179
|
-
|
|
1224
|
+
let confirmBtn = btns.find(b => CONFIRM_WORDS.some(w => buttonHay(b).includes(w)));
|
|
1225
|
+
// Also treat "All" buttons as confirmation (e.g. clicking "Plant All" again on the
|
|
1226
|
+
// confirmation screen advances past it). But prefer explicit confirm words first.
|
|
1227
|
+
if (!confirmBtn) {
|
|
1228
|
+
confirmBtn = btns.find(b => ALL_AS_CONFIRM.some(w => buttonHay(b).includes(w)));
|
|
1229
|
+
}
|
|
1180
1230
|
|
|
1181
1231
|
if (!confirmBtn) {
|
|
1182
1232
|
// No more confirmation buttons — return the current screen (should be manage menu)
|
|
@@ -1192,6 +1242,18 @@ async function advancePastConfirmation(response, waitForDankMemer) {
|
|
|
1192
1242
|
try {
|
|
1193
1243
|
clicked = await safeClickButton(response, confirmBtn);
|
|
1194
1244
|
logEphemeralLike(`confirm-page-${page}`, clicked);
|
|
1245
|
+
// Preserve CV2 ack so cycle can check for ephemeral errors.
|
|
1246
|
+
if (clicked?._lastInteractionAck) {
|
|
1247
|
+
response._lastInteractionAck = clicked._lastInteractionAck;
|
|
1248
|
+
}
|
|
1249
|
+
// Also capture ephemeral ack content for logging
|
|
1250
|
+
const ackContent = captureCvvAckContent(clicked);
|
|
1251
|
+
if (ackContent) {
|
|
1252
|
+
const flags = clicked?.flags ?? 0;
|
|
1253
|
+
const isEphemeral = (flags & 64) !== 0;
|
|
1254
|
+
const level = isEphemeral ? LOG.warn : LOG.info;
|
|
1255
|
+
level(`[farm:confirm:${page}] ephemeral=${isEphemeral} ack="${ackContent.slice(0, 300)}"`);
|
|
1256
|
+
}
|
|
1195
1257
|
} catch (e) {
|
|
1196
1258
|
LOG.warn(`[farm:confirm] click failed on page ${page}: ${e.message}`);
|
|
1197
1259
|
return response;
|
|
@@ -1235,7 +1297,7 @@ async function advancePastConfirmation(response, waitForDankMemer) {
|
|
|
1235
1297
|
// ── Single-cycle farm orchestrator ──────────────────────────────────────────
|
|
1236
1298
|
// Sends `pls farm view` once, then completes the full hoe→water→plant→harvest
|
|
1237
1299
|
// cycle by looping on the returned manage menu — no additional command sends.
|
|
1238
|
-
async function runFarm({ channel, waitForDankMemer, client, redis, accountId }) {
|
|
1300
|
+
async function runFarm({ channel, waitForDankMemer, client, redis, accountId, forceRun }) {
|
|
1239
1301
|
LOG.cmd(`${c.white}${c.bold}pls farm view${c.reset}`);
|
|
1240
1302
|
|
|
1241
1303
|
await channel.send('pls farm view');
|
|
@@ -1291,7 +1353,7 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
|
|
|
1291
1353
|
if (textHasHarvestReady) LOG.info(`[farm] DETECTED harvest-ready in text (growReadySec=${growReadySec})`);
|
|
1292
1354
|
if (growReadySec > 0) LOG.info(`[farm] GROW-QUEUE DEBUG: lower=${lower.slice(0, 400)}`);
|
|
1293
1355
|
LOG.info(`[farm] grow-ready parse=${growReadySec == null ? 'none' : `${growReadySec}s`} redis_recovery=${redisRecoveryMs != null ? `${redisRecoveryMs}ms` : 'n/a'}`);
|
|
1294
|
-
if (!redisRecovered && growReadySec && growReadySec > 20) {
|
|
1356
|
+
if (!forceRun && !redisRecovered && growReadySec && growReadySec > 20) {
|
|
1295
1357
|
await inferPreferredActionFromImage(response);
|
|
1296
1358
|
// Re-check every 30s instead of waiting the full grow duration.
|
|
1297
1359
|
// This ensures the next harvest cycle starts as soon as crops are ready.
|
|
@@ -1428,6 +1490,30 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
|
|
|
1428
1490
|
label.includes('plant all') || label.includes('harvest all') || label.includes('confirm')) && b.disabled;
|
|
1429
1491
|
});
|
|
1430
1492
|
if (disabledAllBtn) {
|
|
1493
|
+
// For harvest: disabled means nothing to harvest — skip to hoe.
|
|
1494
|
+
// For hoe/water/plant: disabled means we don't have the item — try to buy it.
|
|
1495
|
+
const ITEM_FOR_ACTION = { hoe: 'Hoe', water: 'Watering Can', plant: 'Seeds' };
|
|
1496
|
+
const missingItem = ITEM_FOR_ACTION[action];
|
|
1497
|
+
if (missingItem) {
|
|
1498
|
+
LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled — trying to buy ${missingItem}`);
|
|
1499
|
+
const bought = await tryBuyFarmItem({ missing: missingItem, channel, waitForDankMemer, client });
|
|
1500
|
+
if (bought.ok) {
|
|
1501
|
+
LOG.success(`[farm:cycle:${cycleDepth}] Bought ${bought.itemName} — retrying ${action}`);
|
|
1502
|
+
await sleep(1200);
|
|
1503
|
+
// Restart the cycle from farm view to refresh button state
|
|
1504
|
+
await channel.send('pls farm view');
|
|
1505
|
+
const re = await waitForDankMemer(12000);
|
|
1506
|
+
if (!re) { LOG.warn('[farm:cycle] no response after buy retry'); break; }
|
|
1507
|
+
if (isCV2(re)) await ensureCV2(re);
|
|
1508
|
+
cycleResponse = re;
|
|
1509
|
+
text = getFullText(cycleResponse);
|
|
1510
|
+
clean = brief(text, 600);
|
|
1511
|
+
initialAnalysis = null;
|
|
1512
|
+
continue;
|
|
1513
|
+
} else {
|
|
1514
|
+
LOG.warn(`[farm:cycle:${cycleDepth}] Could not buy ${missingItem} — skipping to next phase`);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1431
1517
|
const currentIdx = FARM_PHASE_ORDER.indexOf(action);
|
|
1432
1518
|
const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
|
|
1433
1519
|
LOG.info(`[farm:cycle:${cycleDepth}] ${action} all-btn disabled (no-op) — next=${nextPhase || 'done'}`);
|
|
@@ -1455,12 +1541,98 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
|
|
|
1455
1541
|
// Step 5: advance past any confirmation screens back to the manage menu
|
|
1456
1542
|
cycleResponse = await advancePastConfirmation(lastApplyResp, waitForDankMemer);
|
|
1457
1543
|
if (!cycleResponse) { LOG.warn('[farm:cycle] confirmation advance returned null'); break; }
|
|
1544
|
+
if (isCV2(cycleResponse)) await ensureCV2(cycleResponse);
|
|
1458
1545
|
actionsTaken++;
|
|
1459
1546
|
lastAction = action;
|
|
1460
1547
|
text = getFullText(cycleResponse);
|
|
1461
1548
|
clean = brief(text, 600);
|
|
1462
1549
|
logFarmState('after-confirm', cycleResponse);
|
|
1463
1550
|
|
|
1551
|
+
// ── Farm text detection: determine next action from farm state ─────────────────
|
|
1552
|
+
// After confirmation advances, we may be on the farm view (no action tabs visible).
|
|
1553
|
+
// Use farm text patterns to determine what to do next — this is more reliable
|
|
1554
|
+
// than the button state check (which fails on the farm view).
|
|
1555
|
+
{
|
|
1556
|
+
const postBtns = getAllButtons(cycleResponse);
|
|
1557
|
+
const postActionBtns = postBtns.filter(b => !b.disabled && !isNavOrUtilityButton(b));
|
|
1558
|
+
const postHasActionTabs = postActionBtns.some(b => hasAny(b, ['hoe', 'water', 'plant', 'harvest', 'fertiliz']));
|
|
1559
|
+
const postLower = clean.toLowerCase();
|
|
1560
|
+
const hasSeedsReady = /seeds ready|seeds.*ready|planted|crop.*ready/i.test(postLower);
|
|
1561
|
+
const hasHarvestReady = /ready to harvest|harvest ready|can be harvest|can harvest|wilt/i.test(postLower);
|
|
1562
|
+
const isEmpty = /pretty empty|seems empty|empty\.{0,3}/i.test(postLower) && !hasSeedsReady && !hasHarvestReady;
|
|
1563
|
+
if (!postHasActionTabs && !isEmpty) {
|
|
1564
|
+
// Farm has crops (planted/growing). Force harvest.
|
|
1565
|
+
// cycleResponse is still the farm view (no action tabs) after advancePastConfirmation.
|
|
1566
|
+
// Re-enter the manage menu so findNextFarmActionFromManage has buttons to work with.
|
|
1567
|
+
const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
|
|
1568
|
+
if (mb) {
|
|
1569
|
+
LOG.info(`[farm:cycle:${cycleDepth}] farm-text: re-entering manage menu to force harvest`);
|
|
1570
|
+
const mr = await clickAndCapture({ channel, waitForDankMemer, response: cycleResponse, button: mb, tag: 'farm-farmtext-harvest-manage' });
|
|
1571
|
+
if (mr) {
|
|
1572
|
+
if (isCV2(mr)) await ensureCV2(mr);
|
|
1573
|
+
cycleResponse = mr;
|
|
1574
|
+
text = getFullText(cycleResponse);
|
|
1575
|
+
clean = brief(text, 600);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
forcedNextAction = 'harvest';
|
|
1579
|
+
lastAction = null;
|
|
1580
|
+
cycleDepth++;
|
|
1581
|
+
initialAnalysis = null;
|
|
1582
|
+
await sleep(300);
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
if (!postHasActionTabs && isEmpty) {
|
|
1586
|
+
// Farm is empty after the action. Re-enter manage menu before forcing hoe.
|
|
1587
|
+
const mb = findFarmButton(cycleResponse, ['manage', 'farm-farm:manage']);
|
|
1588
|
+
if (mb) {
|
|
1589
|
+
LOG.info(`[farm:cycle:${cycleDepth}] farm-text: re-entering manage menu (empty farm)`);
|
|
1590
|
+
const mr = await clickAndCapture({ channel, waitForDankMemer, response: cycleResponse, button: mb, tag: 'farm-farmtext-hoe-manage' });
|
|
1591
|
+
if (mr) {
|
|
1592
|
+
if (isCV2(mr)) await ensureCV2(mr);
|
|
1593
|
+
cycleResponse = mr;
|
|
1594
|
+
text = getFullText(cycleResponse);
|
|
1595
|
+
clean = brief(text, 600);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
forcedNextAction = 'hoe';
|
|
1599
|
+
lastAction = null;
|
|
1600
|
+
cycleDepth++;
|
|
1601
|
+
initialAnalysis = null;
|
|
1602
|
+
await sleep(300);
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
// Otherwise: we're on manage menu or have action tabs — proceed to find next action.
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// ── Harvest ephemeral: parse "You harvested: Nothing" to detect no-op ──────────
|
|
1609
|
+
if (action === 'harvest') {
|
|
1610
|
+
const ephemeral = lastApplyResp?._capturedEphemeral;
|
|
1611
|
+
if (ephemeral) {
|
|
1612
|
+
const cv2t = ephemeral.cv2Text || '';
|
|
1613
|
+
const allt = ephemeral.allText || '';
|
|
1614
|
+
const harvestText = cv2t || allt;
|
|
1615
|
+
LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] "${harvestText.slice(0, 300)}"`);
|
|
1616
|
+
// If nothing was harvested, cells are now empty (post-harvest debris).
|
|
1617
|
+
// Force the cycle to proceed to hoe rather than re-analyzing the same state.
|
|
1618
|
+
if (/nothing|0\s*x\s*\w|no\s+crops|\bnone\b/i.test(harvestText)) {
|
|
1619
|
+
LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] Nothing harvested — forcing next action to hoe`);
|
|
1620
|
+
forcedNextAction = 'hoe';
|
|
1621
|
+
lastAction = 'harvest';
|
|
1622
|
+
cycleDepth++;
|
|
1623
|
+
initialAnalysis = null;
|
|
1624
|
+
await sleep(300);
|
|
1625
|
+
continue;
|
|
1626
|
+
}
|
|
1627
|
+
// Something was actually harvested — parse what and log it.
|
|
1628
|
+
const itemMatches = [...harvestText.matchAll(/-?\s*(\d+)\s*x?\s*([A-Za-z]+)/g)];
|
|
1629
|
+
if (itemMatches.length > 0) {
|
|
1630
|
+
const items = itemMatches.map(m => `${m[1]}x ${m[2]}`).join(', ');
|
|
1631
|
+
LOG.info(`[farm:cycle:${cycleDepth}:harvest-ephemeral] Harvested: ${items}`);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1464
1636
|
// If confirmation screen is identical to post-confirmation screen,
|
|
1465
1637
|
// check whether we're on a planted confirmation (Dank Memer planted crops
|
|
1466
1638
|
// and shows "ready at X"). In that case, click "Back" to return to the
|
|
@@ -1532,17 +1704,38 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
|
|
|
1532
1704
|
const score = scoreResult?.score || null;
|
|
1533
1705
|
const nextAction = inferNextActionFromScore(action, score);
|
|
1534
1706
|
|
|
1707
|
+
// Collect all error/rejection text sources:
|
|
1708
|
+
// 1. Extra follow-up messages from farm actions
|
|
1709
|
+
// 2. CV2 interaction ACK (ephemeral or success/error response)
|
|
1710
|
+
// 3. The main message text after the action
|
|
1711
|
+
const ackContent = captureCvvAckContent(lastApplyResp?._capturedAck);
|
|
1712
|
+
const extraContent = lastApplyResp?._farmExtraMsg
|
|
1713
|
+
? String(stripAnsi(getFullText(lastApplyResp._farmExtraMsg) || '')).replace(/\s+/g, ' ').trim()
|
|
1714
|
+
: '';
|
|
1715
|
+
const postContent = String(stripAnsi(getFullText(lastApplyResp) || '')).replace(/\s+/g, ' ').trim();
|
|
1716
|
+
|
|
1717
|
+
if (ackContent || extraContent) {
|
|
1718
|
+
const flags = lastApplyResp?._capturedAck?.flags ?? 0;
|
|
1719
|
+
const isEphemeral = (flags & 64) !== 0;
|
|
1720
|
+
const level = isEphemeral ? LOG.warn : LOG.info;
|
|
1721
|
+
level(`[farm:cycle:${cycleDepth}:ephemeral] ephemeral=${isEphemeral} ack="${ackContent.slice(0, 300)}" extra="${extraContent.slice(0, 200)}"`);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// Build combined error text for rejection detection
|
|
1725
|
+
const allErrorText = [extraContent, ackContent].filter(Boolean).join(' ');
|
|
1726
|
+
|
|
1535
1727
|
// Handle rejection messages that indicate wrong phase.
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1728
|
+
if (action === 'plant' && /only\s+plant\s+seeds\s+on\s+an\s+empty\s+tile|tilled\s+and\s+watered|can only plant seeds on an empty tile/.test(allErrorText)) {
|
|
1729
|
+
LOG.warn(`[farm:cycle] Plant rejected — need hoe+water first. Restarting from hoe. err="${allErrorText.slice(0, 200)}"`);
|
|
1730
|
+
forcedNextAction = 'hoe';
|
|
1539
1731
|
lastAction = null;
|
|
1540
1732
|
cycleDepth++;
|
|
1541
1733
|
await sleep(400);
|
|
1542
1734
|
continue;
|
|
1543
1735
|
}
|
|
1544
|
-
if (action === 'hoe' && /can only use.*hoe.*empty tile|after a harvest/.test(
|
|
1736
|
+
if (action === 'hoe' && /can only use.*hoe.*empty tile|after a harvest/.test(allErrorText)) {
|
|
1545
1737
|
LOG.warn('[farm:cycle] Hoe rejected — moving to water phase.');
|
|
1738
|
+
forcedNextAction = 'water';
|
|
1546
1739
|
lastAction = 'hoe';
|
|
1547
1740
|
cycleDepth++;
|
|
1548
1741
|
await sleep(400);
|
|
@@ -1551,6 +1744,22 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
|
|
|
1551
1744
|
|
|
1552
1745
|
if (!nextAction) {
|
|
1553
1746
|
LOG.info(`[farm:cycle:${cycleDepth}] ${action} done — no next action (score=${score?.score ?? '?'}/${score?.threshold ?? '?'}, conf=${score?.confidence ?? '?'})`);
|
|
1747
|
+
// Enforce strict phase ordering: after water → harvest. Never route to plant from image analysis.
|
|
1748
|
+
if (action === 'water') {
|
|
1749
|
+
const harvestIdx = FARM_PHASE_ORDER.indexOf('harvest');
|
|
1750
|
+
if (harvestIdx >= 0) {
|
|
1751
|
+
const harvestBtn = managedActions.harvest || getAllButtons(cycleResponse).find(b => hasAny(b, ['harvest', 'reap', 'collect']));
|
|
1752
|
+
if (harvestBtn) {
|
|
1753
|
+
LOG.info(`[farm:cycle:${cycleDepth}] enforcing harvest after water (strict phase order)`);
|
|
1754
|
+
forcedNextAction = 'harvest';
|
|
1755
|
+
cycleDepth++;
|
|
1756
|
+
lastAction = 'water';
|
|
1757
|
+
initialAnalysis = null;
|
|
1758
|
+
await sleep(300);
|
|
1759
|
+
continue;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1554
1763
|
break;
|
|
1555
1764
|
}
|
|
1556
1765
|
|
|
@@ -82,16 +82,11 @@ function extractFarmImageUrl(msg) {
|
|
|
82
82
|
function colorStatsForPixel(r, g, b) {
|
|
83
83
|
const sum = r + g + b;
|
|
84
84
|
const brightness = sum / 3;
|
|
85
|
-
//
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
const weedGreen = g > r * 1.12 && g > b * 1.1 && g <= 38;
|
|
89
|
-
// Wet soil — rich dark brown, no blue, higher R than typical dry soil
|
|
90
|
-
const wetSoil = r > 50 && r > g * 1.5 && b < 20 && brightness < 45;
|
|
91
|
-
// Dry tilled soil — medium brown, debris textures
|
|
92
|
-
const drySoil = r > 35 && r > g * 1.2 && b < 30;
|
|
85
|
+
// Grayscale: removes hue confusion between dusty-brown and green
|
|
86
|
+
const gray = Math.round(r * 0.299 + g * 0.587 + b * 0.114);
|
|
87
|
+
const soil = r >= g && b < 30;
|
|
93
88
|
const whiteish = brightness > 210 && Math.abs(r - g) < 20 && Math.abs(g - b) < 20;
|
|
94
|
-
return { brightness,
|
|
89
|
+
return { brightness, gray, soil, whiteish };
|
|
95
90
|
}
|
|
96
91
|
|
|
97
92
|
function clamp01(n) {
|
|
@@ -99,41 +94,79 @@ function clamp01(n) {
|
|
|
99
94
|
}
|
|
100
95
|
|
|
101
96
|
function classifyCell(features) {
|
|
102
|
-
const {
|
|
97
|
+
const {
|
|
98
|
+
harvestPct, plantPct, soilPct,
|
|
99
|
+
avgBrightness, grayStd,
|
|
100
|
+
brightGrayPct, darkGrayPct,
|
|
101
|
+
avgSat, avgR, avgG, avgB,
|
|
102
|
+
gMinusR, discordUiPct,
|
|
103
|
+
} = features;
|
|
104
|
+
|
|
105
|
+
// If Discord UI dominates this cell, the stats are meaningless
|
|
106
|
+
if (discordUiPct > 0.5) {
|
|
107
|
+
return { state: 'unknown', confidence: 0.1, scores: { harvest: 0, planted: 0, soil: 0 } };
|
|
108
|
+
}
|
|
103
109
|
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
// Actual farm pixel analysis from live screenshot:
|
|
111
|
+
// Water cells: sat=0.15-0.20, B=14-21, darkPct=64-91%, G-R=-20 to +4
|
|
112
|
+
// Tilled cells: sat=0.22-0.25, B=12-17, darkPct=64-81%, G-R=-8 to +11
|
|
113
|
+
// Harvest cells: sat=0.22-0.25, B=12-17, darkPct=64-73%, G-R=+4 to +11, harv=41-46%
|
|
114
|
+
//
|
|
115
|
+
// Key insight: sat < 0.20 + B > 14 = water cells
|
|
116
|
+
// Higher sat (0.22+) + harvestPixels > 40% = harvest crops
|
|
117
|
+
// Higher sat (0.22+) + harv < 40% = tilled soil
|
|
118
|
+
|
|
119
|
+
const isLowSatWater = avgSat < 0.20 && avgB > 14; // wet/dark soil
|
|
120
|
+
const isHighSatTilled = avgSat >= 0.20; // tilled/planted soil
|
|
121
|
+
const isBright = brightGrayPct > 0.15; // has bright crop pixels
|
|
122
|
+
const isDarkUniform = darkGrayPct > 0.55 && grayStd < 20; // very dark uniform (wet soil)
|
|
123
|
+
|
|
124
|
+
// ── HARVEST: Bright crops + high green + sat ≥ 0.20 ──
|
|
125
|
+
// Growing/ready crops: sat 0.22+, G-R > 5, harvestPixels > 40%
|
|
126
|
+
if (avgSat >= 0.20 && gMinusR > 5 && harvestPct > 0.40 && isBright) {
|
|
127
|
+
const excess = gMinusR - 5;
|
|
128
|
+
const confidence = clamp01(0.5 + excess * 0.06);
|
|
129
|
+
return { state: 'harvest', confidence: +confidence.toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
|
|
109
130
|
}
|
|
110
131
|
|
|
111
|
-
// ──
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// logic handles water vs hoe state transition by phase context.
|
|
116
|
-
const confidence = clamp01((brownPct - 0.15) * 2.5 + (1 - darkPct) * 0.3);
|
|
117
|
-
return { state: 'tilled', confidence: +confidence.toFixed(3), scores: { planted: +greenPct.toFixed(3), wet: +darkPct.toFixed(3), tilled: +brownPct.toFixed(3) } };
|
|
132
|
+
// ── WATER: Low saturation + blue dominant (wet soil) ──
|
|
133
|
+
// sat < 0.20 AND B > 14 catches actual wet cells from live screenshot
|
|
134
|
+
if (isLowSatWater || isDarkUniform) {
|
|
135
|
+
return { state: 'water', confidence: +clamp01(0.5 + darkGrayPct * 0.3).toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
|
|
118
136
|
}
|
|
119
137
|
|
|
120
|
-
// ──
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
138
|
+
// ── TILLED: Higher saturation (≥ 0.20) = soil/debris ──
|
|
139
|
+
// sat ≥ 0.20 covers tilled dirt, debris, and planted cells
|
|
140
|
+
if (isHighSatTilled) {
|
|
141
|
+
// Planted if some harvest pixels and some brightness
|
|
142
|
+
if (plantPct > 0.15 || gMinusR > 1) {
|
|
143
|
+
const confidence = clamp01(0.3 + avgSat * 0.8 + brightGrayPct * 0.5);
|
|
144
|
+
return { state: 'planted', confidence: +confidence.toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
|
|
145
|
+
}
|
|
146
|
+
const confidence = clamp01((avgSat - 0.15) * 1.5);
|
|
147
|
+
return { state: 'tilled', confidence: +confidence.toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
|
|
126
148
|
}
|
|
127
149
|
|
|
128
|
-
// ── UNKNOWN: Low signal
|
|
129
|
-
const signal = Math.max(
|
|
150
|
+
// ── UNKNOWN: Low signal / empty ──
|
|
151
|
+
const signal = Math.max(harvestPct, plantPct, soilPct, avgSat);
|
|
130
152
|
const confidence = signal * 1.5;
|
|
131
|
-
return { state: 'unknown', confidence: +confidence.toFixed(3), scores: {
|
|
153
|
+
return { state: 'unknown', confidence: +confidence.toFixed(3), scores: { harvest: +harvestPct.toFixed(3), planted: +plantPct.toFixed(3), soil: +soilPct.toFixed(3) } };
|
|
132
154
|
}
|
|
133
155
|
|
|
134
156
|
async function analyzeFarmGrid(imgBuffer, cols = 3, rows = 3) {
|
|
135
|
-
|
|
136
|
-
const
|
|
157
|
+
// Convert to grayscale to eliminate hue confusion between dusty-brown and green
|
|
158
|
+
const grayBuf = await sharp(imgBuffer).grayscale().raw().toBuffer({ resolveWithObject: true });
|
|
159
|
+
const { data: grayData, info: grayInfo } = grayBuf;
|
|
160
|
+
const { width, height, channels } = grayInfo;
|
|
161
|
+
|
|
162
|
+
// Also get original for saturation analysis
|
|
163
|
+
const origBuf = await sharp(imgBuffer).raw().toBuffer({ resolveWithObject: true });
|
|
164
|
+
const { data: origData, info: origInfo } = origBuf;
|
|
165
|
+
|
|
166
|
+
if (origInfo.width !== width || origInfo.height !== height) {
|
|
167
|
+
throw new Error('Grayscale/Original dimension mismatch — should not happen');
|
|
168
|
+
}
|
|
169
|
+
|
|
137
170
|
const cellW = Math.floor(width / cols);
|
|
138
171
|
const cellH = Math.floor(height / rows);
|
|
139
172
|
|
|
@@ -145,60 +178,103 @@ async function analyzeFarmGrid(imgBuffer, cols = 3, rows = 3) {
|
|
|
145
178
|
const ex = Math.min(sx + cellW, width);
|
|
146
179
|
const ey = Math.min(sy + cellH, height);
|
|
147
180
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
181
|
+
// Use 30% vertical padding to avoid Discord UI bar bleeding into top/bottom rows
|
|
182
|
+
const padX = Math.floor((ex - sx) * 0.20);
|
|
183
|
+
const padY = Math.floor((ey - sy) * 0.30);
|
|
184
|
+
const csx = sx + padX;
|
|
185
|
+
const csy = sy + padY;
|
|
186
|
+
const cex = ex - padX;
|
|
187
|
+
const cey = ey - padY;
|
|
155
188
|
|
|
156
189
|
let total = 0;
|
|
157
|
-
let
|
|
158
|
-
let
|
|
159
|
-
let
|
|
160
|
-
let
|
|
161
|
-
let
|
|
190
|
+
let harvestPx = 0;
|
|
191
|
+
let plantPx = 0;
|
|
192
|
+
let soilPx = 0;
|
|
193
|
+
let graySum = 0, graySqSum = 0;
|
|
194
|
+
let brightGrayPx = 0;
|
|
195
|
+
let darkGrayPx = 0;
|
|
196
|
+
let rSum = 0, gSum = 0, bSum = 0, satSum = 0;
|
|
197
|
+
let discordUiPx = 0;
|
|
198
|
+
const rawCellPixels = (cex - csx) * (cey - csy);
|
|
162
199
|
|
|
163
200
|
for (let y = csy; y < cey; y++) {
|
|
164
201
|
for (let x = csx; x < cex; x++) {
|
|
165
202
|
const idx = (y * width + x) * channels;
|
|
166
|
-
const r =
|
|
167
|
-
const g =
|
|
168
|
-
const b =
|
|
169
|
-
const
|
|
203
|
+
const r = origData[idx];
|
|
204
|
+
const g = origData[idx + 1];
|
|
205
|
+
const b = origData[idx + 2];
|
|
206
|
+
const gray = grayData[idx];
|
|
207
|
+
|
|
208
|
+
const maxC = Math.max(r, g, b);
|
|
209
|
+
const minC = Math.min(r, g, b);
|
|
210
|
+
const sat = maxC > 0 ? (maxC - minC) / 255 : 0;
|
|
211
|
+
|
|
212
|
+
// Skip Discord UI blue bar: very low saturation + blue dominant
|
|
213
|
+
if (b > 80 && sat < 0.08) {
|
|
214
|
+
discordUiPx++;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
170
218
|
total++;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
219
|
+
graySum += gray;
|
|
220
|
+
graySqSum += gray * gray;
|
|
221
|
+
rSum += r; gSum += g; bSum += b;
|
|
222
|
+
satSum += sat;
|
|
223
|
+
|
|
224
|
+
if (g > r * 1.25) harvestPx++;
|
|
225
|
+
else if (g > r * 1.08) plantPx++;
|
|
226
|
+
if (r >= g) soilPx++;
|
|
227
|
+
if (gray > 100) brightGrayPx++;
|
|
228
|
+
if (gray < 65) darkGrayPx++;
|
|
176
229
|
}
|
|
177
230
|
}
|
|
178
231
|
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
const
|
|
232
|
+
const harvestPct = total > 0 ? harvestPx / total : 0;
|
|
233
|
+
const plantPct = total > 0 ? plantPx / total : 0;
|
|
234
|
+
const soilPct = total > 0 ? soilPx / total : 0;
|
|
235
|
+
const grayMean = total > 0 ? graySum / total : 0;
|
|
236
|
+
const grayVar = total > 0 ? (graySqSum / total) - (grayMean * grayMean) : 0;
|
|
237
|
+
const grayStd = Math.sqrt(Math.max(0, grayVar));
|
|
238
|
+
const avgSat = total > 0 ? satSum / total : 0;
|
|
239
|
+
const avgR = total > 0 ? rSum / total : 0;
|
|
240
|
+
const avgG = total > 0 ? gSum / total : 0;
|
|
241
|
+
const brightGrayPct = total > 0 ? brightGrayPx / total : 0;
|
|
242
|
+
const darkGrayPct = total > 0 ? darkGrayPx / total : 0;
|
|
243
|
+
const gMinusR = avgG - avgR;
|
|
244
|
+
const avgB = total > 0 ? bSum / total : 0;
|
|
245
|
+
const discordUiPct = rawCellPixels > 0 ? discordUiPx / rawCellPixels : 0;
|
|
246
|
+
|
|
247
|
+
const classified = classifyCell({
|
|
248
|
+
harvestPct, plantPct, soilPct,
|
|
249
|
+
avgR, avgG, avgB,
|
|
250
|
+
avgBrightness: grayMean,
|
|
251
|
+
avgSat, grayStd,
|
|
252
|
+
brightGrayPct, darkGrayPct,
|
|
253
|
+
gMinusR,
|
|
254
|
+
discordUiPct,
|
|
255
|
+
});
|
|
186
256
|
|
|
187
257
|
cells.push({
|
|
188
|
-
row,
|
|
189
|
-
col,
|
|
258
|
+
row, col,
|
|
190
259
|
state: classified.state,
|
|
191
260
|
confidence: classified.confidence,
|
|
192
261
|
scores: classified.scores,
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
avgBrightness: +
|
|
262
|
+
harvestPct: +harvestPct.toFixed(3),
|
|
263
|
+
plantPct: +plantPct.toFixed(3),
|
|
264
|
+
soilPct: +soilPct.toFixed(3),
|
|
265
|
+
avgBrightness: +grayMean.toFixed(1),
|
|
266
|
+
avgSat: +avgSat.toFixed(3),
|
|
267
|
+
grayStd: +grayStd.toFixed(1),
|
|
268
|
+
brightGrayPct: +brightGrayPct.toFixed(3),
|
|
269
|
+
darkGrayPct: +darkGrayPct.toFixed(3),
|
|
270
|
+
avgR: +avgR.toFixed(1),
|
|
271
|
+
avgG: +avgG.toFixed(1),
|
|
272
|
+
avgB: +avgB.toFixed(1),
|
|
197
273
|
});
|
|
198
274
|
}
|
|
199
275
|
}
|
|
200
276
|
|
|
201
|
-
const counts = { tilled: 0, planted: 0, unknown: 0 };
|
|
277
|
+
const counts = { harvest: 0, water: 0, tilled: 0, planted: 0, unknown: 0 };
|
|
202
278
|
for (const c of cells) counts[c.state] = (counts[c.state] || 0) + 1;
|
|
203
279
|
|
|
204
280
|
const avgConfidence = cells.length > 0
|
|
@@ -209,7 +285,7 @@ async function analyzeFarmGrid(imgBuffer, cols = 3, rows = 3) {
|
|
|
209
285
|
}
|
|
210
286
|
|
|
211
287
|
function gridToString(analysis) {
|
|
212
|
-
const icon = { tilled: 'T', planted: 'P', unknown: '?' };
|
|
288
|
+
const icon = { harvest: 'H', water: 'W', tilled: 'T', planted: 'P', unknown: '?' };
|
|
213
289
|
const rows = [];
|
|
214
290
|
for (let r = 0; r < (analysis?.rows || 0); r++) {
|
|
215
291
|
const line = [];
|
|
@@ -224,9 +300,10 @@ function gridToString(analysis) {
|
|
|
224
300
|
|
|
225
301
|
function evaluateActionNeed(actionName, analysis) {
|
|
226
302
|
const c = analysis?.counts || {};
|
|
227
|
-
const
|
|
228
|
-
const wet = c.wet || 0;
|
|
303
|
+
const harvest = c.harvest || 0;
|
|
229
304
|
const planted = c.planted || 0;
|
|
305
|
+
const water = c.water || 0;
|
|
306
|
+
const tilled = (c.tilled || 0) + (c.soil || 0); // soil = tilled cells
|
|
230
307
|
const unknown = c.unknown || 0;
|
|
231
308
|
const conf = analysis?.avgConfidence || 0;
|
|
232
309
|
const slots = 9;
|
|
@@ -234,35 +311,41 @@ function evaluateActionNeed(actionName, analysis) {
|
|
|
234
311
|
// Avoid aggressive repeats when confidence is too low.
|
|
235
312
|
if (conf < 0.20) return false;
|
|
236
313
|
|
|
237
|
-
//
|
|
238
|
-
// - hoe: repeat
|
|
239
|
-
// - water:
|
|
240
|
-
// - plant: repeat
|
|
241
|
-
// - harvest: repeat if any
|
|
314
|
+
// Rules (4-state model: harvest/water/tilled/planted):
|
|
315
|
+
// - hoe: repeat if farm has no soil (all harvest/unknown)
|
|
316
|
+
// - water: repeat if farm has tilled but not watered (R<58, not water)
|
|
317
|
+
// - plant: repeat if farm has watered tiles but no planted
|
|
318
|
+
// - harvest: repeat if any harvest-ready crops visible
|
|
242
319
|
if (actionName === 'hoe') {
|
|
243
|
-
return
|
|
320
|
+
return harvest < slots && unknown < 6;
|
|
321
|
+
}
|
|
322
|
+
if (actionName === 'water') {
|
|
323
|
+
return tilled > 0 && water < slots;
|
|
244
324
|
}
|
|
245
325
|
if (actionName === 'plant') {
|
|
246
|
-
return planted < slots && (tilled) > 0;
|
|
326
|
+
return planted < slots && (water + tilled) > 0;
|
|
247
327
|
}
|
|
248
328
|
if (actionName === 'harvest') {
|
|
249
|
-
return
|
|
329
|
+
return harvest > 0;
|
|
250
330
|
}
|
|
251
|
-
// water and others: don't force repeats
|
|
252
331
|
return false;
|
|
253
332
|
}
|
|
254
333
|
|
|
255
334
|
function evaluateActionScores(actionName, analysis) {
|
|
256
335
|
const c = analysis?.counts || {};
|
|
257
336
|
const slots = Math.max(1, (analysis?.rows || 3) * (analysis?.cols || 3));
|
|
258
|
-
const
|
|
337
|
+
const harvest = c.harvest || 0;
|
|
259
338
|
const planted = c.planted || 0;
|
|
339
|
+
const water = c.water || 0;
|
|
340
|
+
const tilled = (c.tilled || 0) + (c.soil || 0); // soil = tilled cells
|
|
260
341
|
const unknown = c.unknown || 0;
|
|
261
342
|
const conf = analysis?.avgConfidence || 0;
|
|
262
343
|
|
|
263
344
|
const ratios = {
|
|
264
|
-
|
|
345
|
+
harvest: harvest / slots,
|
|
265
346
|
planted: planted / slots,
|
|
347
|
+
water: water / slots,
|
|
348
|
+
tilled: tilled / slots,
|
|
266
349
|
unknown: unknown / slots,
|
|
267
350
|
};
|
|
268
351
|
|
|
@@ -274,24 +357,33 @@ function evaluateActionScores(actionName, analysis) {
|
|
|
274
357
|
let reason = 'ok';
|
|
275
358
|
|
|
276
359
|
if (actionName === 'hoe') {
|
|
277
|
-
// Hoe is good if farm has mostly soil
|
|
278
|
-
const base = (ratios.tilled * 0.
|
|
360
|
+
// Hoe is good if farm has mostly soil, not harvest-ready crops
|
|
361
|
+
const base = (ratios.tilled * 0.7) + (ratios.water * 0.7) + (ratios.planted * 0.5) - (ratios.harvest * 0.9) - (ratios.unknown * 0.3);
|
|
279
362
|
score = withConf(base);
|
|
280
|
-
threshold = 0.
|
|
363
|
+
threshold = 0.40;
|
|
281
364
|
if (ratios.unknown > 0.5) reason = 'too_many_unknown_cells';
|
|
282
|
-
else if (ratios.
|
|
365
|
+
else if (ratios.harvest >= 0.5) reason = 'farm_has_crops_to_harvest';
|
|
366
|
+
else if (ratios.harvest + ratios.planted < 0.3) reason = 'farm_needs_hoe';
|
|
367
|
+
} else if (actionName === 'water') {
|
|
368
|
+
// Water is good if there are tilled (dry) tiles to water
|
|
369
|
+
const base = (ratios.tilled * 0.95) - (ratios.water * 0.5) - (ratios.harvest * 0.5) - (ratios.unknown * 0.2);
|
|
370
|
+
score = withConf(base);
|
|
371
|
+
threshold = 0.40;
|
|
372
|
+
if (ratios.harvest >= 0.5) reason = 'farm_has_crops_to_harvest';
|
|
373
|
+
else if (ratios.tilled < 0.3) reason = 'no_tilled_tiles';
|
|
283
374
|
} else if (actionName === 'plant') {
|
|
284
|
-
// Plant should end with
|
|
285
|
-
const base = (ratios.planted * 0.
|
|
375
|
+
// Plant should end with growing crops on watered soil
|
|
376
|
+
const base = (ratios.planted * 0.95) - (ratios.harvest * 0.1) - (ratios.tilled * 0.2) - (ratios.unknown * 0.1);
|
|
286
377
|
score = withConf(base);
|
|
287
|
-
threshold = 0.
|
|
288
|
-
if (ratios.
|
|
378
|
+
threshold = 0.50;
|
|
379
|
+
if (ratios.harvest >= 0.5) reason = 'farm_ready_to_harvest';
|
|
380
|
+
else if (ratios.water + ratios.tilled < 0.3) reason = 'farm_not_watered';
|
|
289
381
|
} else if (actionName === 'harvest') {
|
|
290
|
-
// Harvest is good if
|
|
291
|
-
const base = (ratios.
|
|
382
|
+
// Harvest is good if harvest-ready tiles dominate
|
|
383
|
+
const base = (ratios.harvest * 0.95) - (ratios.tilled * 0.15) - (ratios.planted * 0.1) - (ratios.unknown * 0.1);
|
|
292
384
|
score = withConf(base);
|
|
293
|
-
threshold = 0.
|
|
294
|
-
if (ratios.
|
|
385
|
+
threshold = 0.40;
|
|
386
|
+
if (ratios.harvest < 0.3) reason = 'no_crops_to_harvest';
|
|
295
387
|
} else {
|
|
296
388
|
score = withConf(0.6 - ratios.unknown * 0.3);
|
|
297
389
|
threshold = 0.6;
|
|
@@ -306,10 +398,12 @@ function evaluateActionScores(actionName, analysis) {
|
|
|
306
398
|
threshold: +threshold.toFixed(3),
|
|
307
399
|
confidence: +conf.toFixed(3),
|
|
308
400
|
reason,
|
|
309
|
-
counts: {
|
|
401
|
+
counts: { harvest, planted, water, tilled, unknown, slots },
|
|
310
402
|
ratios: {
|
|
311
|
-
|
|
403
|
+
harvest: +ratios.harvest.toFixed(3),
|
|
312
404
|
planted: +ratios.planted.toFixed(3),
|
|
405
|
+
water: +ratios.water.toFixed(3),
|
|
406
|
+
tilled: +ratios.tilled.toFixed(3),
|
|
313
407
|
unknown: +ratios.unknown.toFixed(3),
|
|
314
408
|
},
|
|
315
409
|
};
|
package/lib/commands/utils.js
CHANGED
|
@@ -494,9 +494,19 @@ async function ensureCV2(msg, force = false) {
|
|
|
494
494
|
if (rawParsed && rawParsed.components?.length > 0) {
|
|
495
495
|
msg._cv2 = rawParsed.components;
|
|
496
496
|
msg._cv2text = rawParsed.cv2Text || _extractCV2Text(rawParsed.components).trim();
|
|
497
|
-
|
|
497
|
+
const topLevel = rawParsed.buttons?.length > 0
|
|
498
498
|
? rawParsed.buttons.map(b => ({ type: 'BUTTON', label: b.label, customId: b.customId, style: b.style, url: null, disabled: b.disabled, emoji: b.emoji, _raw: b }))
|
|
499
|
-
:
|
|
499
|
+
: [];
|
|
500
|
+
const nested = _extractCV2Buttons(rawParsed.components);
|
|
501
|
+
// Merge, deduplicating by customId so all buttons are captured (top-level cv2Buttons
|
|
502
|
+
// omits some nested "All" buttons that live inside ACTION_ROW containers).
|
|
503
|
+
const seen = new Set();
|
|
504
|
+
const merged = [...topLevel, ...nested].filter(b => {
|
|
505
|
+
const id = b.customId || b.custom_id || '';
|
|
506
|
+
if (!id || seen.has(id)) return false;
|
|
507
|
+
seen.add(id); return true;
|
|
508
|
+
});
|
|
509
|
+
msg._cv2buttons = merged;
|
|
500
510
|
msg._cv2EditedTs = msgEditedTs;
|
|
501
511
|
cv2Cache.set(msg.id, { components: rawParsed.components, editedTimestamp: msgEditedTs });
|
|
502
512
|
return msg;
|
|
@@ -574,7 +584,11 @@ async function clickCV2Button(msg, customId) {
|
|
|
574
584
|
const resp = await _httpPost('https://discord.com/api/v9/interactions', {
|
|
575
585
|
Authorization: token, 'Content-Type': 'application/json',
|
|
576
586
|
}, payload);
|
|
577
|
-
if (resp.status >= 400)
|
|
587
|
+
if (resp.status >= 400) {
|
|
588
|
+
const errMsg = `CV2 click ${resp.status}: ${resp.body.substring(0, 300)}`;
|
|
589
|
+
LOG.warn(`[cv2] ${errMsg}`);
|
|
590
|
+
throw new Error(errMsg);
|
|
591
|
+
}
|
|
578
592
|
|
|
579
593
|
let parsed = null;
|
|
580
594
|
try {
|
package/lib/grinder.js
CHANGED
|
@@ -644,7 +644,11 @@ function renderDashboard() {
|
|
|
644
644
|
if (ls === 0) lsStr = `${R}♥0${c.reset}`;
|
|
645
645
|
else if (ls != null && ls <= 2) lsStr = `${Y}♥${ls}${c.reset}`;
|
|
646
646
|
else if (ls != null) lsStr = `${G}♥${ls}${c.reset}`;
|
|
647
|
-
else
|
|
647
|
+
else {
|
|
648
|
+
// Unknown — pulse to show it's still being determined
|
|
649
|
+
const pulse = PULSE_CHARS[Math.floor(Date.now() / 400) % PULSE_CHARS.length];
|
|
650
|
+
lsStr = `${D}${pulse}♥?${c.reset}`;
|
|
651
|
+
}
|
|
648
652
|
|
|
649
653
|
// ── Level indicator (fixed width so value changes don't jitter) ──
|
|
650
654
|
const lvl = wk._level || 0;
|
|
@@ -1839,9 +1843,15 @@ class AccountWorker {
|
|
|
1839
1843
|
}
|
|
1840
1844
|
}
|
|
1841
1845
|
|
|
1842
|
-
// ── Lifesaver protection: skip crime/search if 0 lifesavers ──
|
|
1846
|
+
// ── Lifesaver protection: skip crime/search if 0 lifesavers or unknown ──
|
|
1843
1847
|
if (cmdName === 'crime' || cmdName === 'search') {
|
|
1844
|
-
//
|
|
1848
|
+
// Unknown (null/undefined): DMs haven't confirmed safety — skip for safety.
|
|
1849
|
+
if (this._lifesavers == null) {
|
|
1850
|
+
this.log('warn', `[${cmdName}] SKIPPED — lifesavers unknown (safety hold)`);
|
|
1851
|
+
await this.setCooldown(cmdName, 600);
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
// Zero: depleted — disable for a long period.
|
|
1845
1855
|
if (this._lifesavers === 0) {
|
|
1846
1856
|
this.log('warn', `[${cmdName}] SKIPPED — 0 lifesavers (in-memory)`);
|
|
1847
1857
|
await this.setCooldown(cmdName, 3600);
|
|
@@ -3125,19 +3135,43 @@ async function start(apiKey, apiUrl) {
|
|
|
3125
3135
|
// Init rawLogger Redis (uses same URL — logs all raw gateway data)
|
|
3126
3136
|
if (REDIS_URL) {
|
|
3127
3137
|
rawLogger.init(REDIS_URL).catch(() => {});
|
|
3128
|
-
// Listen for DM
|
|
3138
|
+
// Listen for DM events across all accounts — update worker state + dashboard LIVE
|
|
3129
3139
|
rawLogger.onDmEvent((event, raw) => {
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3140
|
+
const channelId = raw.channel_id;
|
|
3141
|
+
for (const w of workers) {
|
|
3142
|
+
const isThisWorker = w.client?.user?.dmChannel?.id === channelId;
|
|
3143
|
+
if (!isThisWorker && w.channel?.id !== channelId) continue;
|
|
3144
|
+
|
|
3145
|
+
if (event.type === 'death') {
|
|
3146
|
+
// Update worker's lifesaver count so dashboard ♥ updates in real time
|
|
3147
|
+
if (event.lifesaversLeft >= 0) {
|
|
3148
|
+
const prev = w._lifesavers;
|
|
3149
|
+
w._lifesavers = event.lifesaversLeft;
|
|
3150
|
+
if (event.lifesaversLeft === 0) {
|
|
3151
|
+
w.log?.('error', `DEATH in DMs! 0 lifesavers — disabling crime/search`);
|
|
3152
|
+
w.setCooldown?.('crime', 86400);
|
|
3153
|
+
w.setCooldown?.('search', 86400);
|
|
3154
|
+
sendWebhook?.('DEATH ALERT (DM)', `**${w.username}** died in DMs! **0 lifesavers!**\nCrime/search auto-disabled.`, 0xef4444);
|
|
3155
|
+
} else {
|
|
3156
|
+
w.log?.('warn', `DEATH in DMs — ${event.lifesaversLeft} lifesavers remaining`);
|
|
3157
|
+
if (prev !== event.lifesaversLeft) {
|
|
3158
|
+
w.setCooldown?.('crime', 60);
|
|
3159
|
+
w.setCooldown?.('search', 60);
|
|
3160
|
+
}
|
|
3161
|
+
if (event.lifesaversLeft <= 2) {
|
|
3162
|
+
sendWebhook?.('LOW LIFESAVERS', `**${w.username}** has only **${event.lifesaversLeft}** lifesaver(s) left!`, 0xfbbf24);
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
scheduleRender();
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
if (event.type === 'levelup') {
|
|
3170
|
+
if (event.to > 0) {
|
|
3171
|
+
w._level = event.to;
|
|
3172
|
+
scheduleRender();
|
|
3138
3173
|
}
|
|
3139
3174
|
}
|
|
3140
|
-
sendWebhook?.('DEATH ALERT (DM)', `Account died! **0 lifesavers!**\nCrime/search auto-disabled.`, 0xef4444);
|
|
3141
3175
|
}
|
|
3142
3176
|
});
|
|
3143
3177
|
checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}RawLog${c.reset}`);
|
|
@@ -3457,14 +3491,16 @@ async function start(apiKey, apiUrl) {
|
|
|
3457
3491
|
|
|
3458
3492
|
|
|
3459
3493
|
// Phase 2.75: Check DM history for deaths/level-ups (sequential, fast)
|
|
3460
|
-
|
|
3461
|
-
|
|
3494
|
+
const dmCheckPulse = BRAILLE_SPIN[Math.floor(Date.now() / 80) % BRAILLE_SPIN.length];
|
|
3495
|
+
console.log(` ${rgb(139, 92, 246)}${dmCheckPulse}${c.reset} ${c.dim}Checking DM history...${c.reset}`);
|
|
3496
|
+
let dmDeaths = 0, dmLevelUps = 0, dmNoLs = [], dmUnknown = [];
|
|
3462
3497
|
for (const w of activeWorkers) {
|
|
3463
3498
|
try {
|
|
3464
3499
|
const dm = await w.checkDmHistory();
|
|
3465
3500
|
if (dm.deaths > 0) dmDeaths += dm.deaths;
|
|
3466
3501
|
if (dm.levelUps > 0) dmLevelUps += dm.levelUps;
|
|
3467
3502
|
if (dm.lifesavers === 0) dmNoLs.push(w.username);
|
|
3503
|
+
if (dm.lifesavers === -1) dmUnknown.push(w.username);
|
|
3468
3504
|
// Store level and lifesaver for dashboard
|
|
3469
3505
|
if (dm.currentLevel > 0) w._level = dm.currentLevel;
|
|
3470
3506
|
if (dm.lifesavers >= 0) w._lifesavers = dm.lifesavers;
|
|
@@ -3474,6 +3510,10 @@ async function start(apiKey, apiUrl) {
|
|
|
3474
3510
|
if (dm.lifesavers >= 0) {
|
|
3475
3511
|
const lc = dm.lifesavers === 0 ? rgb(239, 68, 68) : dm.lifesavers <= 2 ? rgb(251, 191, 36) : rgb(52, 211, 153);
|
|
3476
3512
|
parts.push(`${lc}♥${dm.lifesavers}${c.reset}`);
|
|
3513
|
+
} else {
|
|
3514
|
+
// Unknown lifesavers — pulse to show pending
|
|
3515
|
+
const pulse = PULSE_CHARS[Math.floor(Date.now() / 400) % PULSE_CHARS.length];
|
|
3516
|
+
parts.push(`${D}${pulse}♥?${c.reset}`);
|
|
3477
3517
|
}
|
|
3478
3518
|
if (parts.length > 0) {
|
|
3479
3519
|
console.log(` ${c.dim}├${c.reset} ${c.bold}${w.username}${c.reset} ${parts.join(' ')}`);
|
|
@@ -3492,7 +3532,16 @@ async function start(apiKey, apiUrl) {
|
|
|
3492
3532
|
}
|
|
3493
3533
|
}
|
|
3494
3534
|
}
|
|
3495
|
-
|
|
3535
|
+
if (dmUnknown.length > 0) {
|
|
3536
|
+
console.log(` ${rgb(251, 191, 36)}⚠${c.reset} ${c.dim}Lifesavers unknown — live DM monitor active:${c.reset} ${dmUnknown.join(', ')}`);
|
|
3537
|
+
// Crime/search on these accounts will be skipped via safety hold until the live
|
|
3538
|
+
// DM gateway listener detects a death (→ sets count) or confirms clean.
|
|
3539
|
+
}
|
|
3540
|
+
const dmSummaryParts = [];
|
|
3541
|
+
if (dmDeaths > 0) dmSummaryParts.push(`${dmDeaths} deaths`);
|
|
3542
|
+
if (dmLevelUps > 0) dmSummaryParts.push(`${dmLevelUps} level-ups`);
|
|
3543
|
+
if (dmUnknown.length > 0) dmSummaryParts.push(`${dmUnknown.length} pending`);
|
|
3544
|
+
console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}DM check${c.reset} ${dmSummaryParts.length > 0 ? c.dim + dmSummaryParts.join(', ') + c.reset : c.dim + 'clean — no deaths or level-ups' + c.reset}`);
|
|
3496
3545
|
console.log('');
|
|
3497
3546
|
|
|
3498
3547
|
console.log(` ${rgb(139, 92, 246)}${c.bold}>>>${c.reset} ${gradientText('Starting grind loops...', [139, 92, 246], [52, 211, 153])}`);
|
package/lib/rawLogger.js
CHANGED
|
@@ -471,6 +471,19 @@ function verboseLog(event, parsed) {
|
|
|
471
471
|
const dmListeners = [];
|
|
472
472
|
function onDmEvent(fn) { dmListeners.push(fn); }
|
|
473
473
|
|
|
474
|
+
// ── Ephemeral message callbacks ──
|
|
475
|
+
const ephemeralListeners = [];
|
|
476
|
+
function onNextEphemeral(fn) { ephemeralListeners.push(fn); }
|
|
477
|
+
|
|
478
|
+
function _notifyEphemeral(parsed) {
|
|
479
|
+
if (!ephemeralListeners.length) return;
|
|
480
|
+
const listeners = [...ephemeralListeners];
|
|
481
|
+
ephemeralListeners.length = 0;
|
|
482
|
+
for (const fn of listeners) {
|
|
483
|
+
try { fn(parsed); } catch {}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
474
487
|
/**
|
|
475
488
|
* Attach DM logger — monitors Dank Memer DMs for:
|
|
476
489
|
* - Level ups: "You leveled up from level X to Y"
|
|
@@ -585,8 +598,15 @@ function attachRawLogger(client, opts = {}) {
|
|
|
585
598
|
|
|
586
599
|
const event = packet.t === 'MESSAGE_CREATE' ? 'CREATE' : 'UPDATE';
|
|
587
600
|
const parsed = store(d, event); // async but we don't await (fire-and-forget)
|
|
588
|
-
if (parsed.then)
|
|
589
|
-
|
|
601
|
+
if (parsed.then) {
|
|
602
|
+
parsed.then(p => {
|
|
603
|
+
verboseLog(event, p);
|
|
604
|
+
if (p?.isEphemeral) _notifyEphemeral(p);
|
|
605
|
+
}).catch(() => {});
|
|
606
|
+
} else {
|
|
607
|
+
verboseLog(event, parsed);
|
|
608
|
+
if (parsed?.isEphemeral) _notifyEphemeral(parsed);
|
|
609
|
+
}
|
|
590
610
|
});
|
|
591
611
|
}
|
|
592
612
|
|
|
@@ -673,6 +693,7 @@ module.exports = {
|
|
|
673
693
|
attachRawLogger,
|
|
674
694
|
attachDmLogger,
|
|
675
695
|
onDmEvent,
|
|
696
|
+
onNextEphemeral,
|
|
676
697
|
setVerbose,
|
|
677
698
|
// Memory reads
|
|
678
699
|
getRawMessage,
|