pog-mcp 0.7.0 → 0.8.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/dist/client.d.ts +30 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +23 -2
- package/dist/client.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +287 -49
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +35 -26
package/dist/server.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { createRequire } from 'node:module';
|
|
12
12
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
13
|
import { z } from 'zod';
|
|
14
|
-
import { ApiError, FORUM_REACTIONS, FORUM_SCOPES, LEADERBOARD_MAX_LIMIT, PogClient, } from './client.js';
|
|
14
|
+
import { HISTORY_STREAM_START, ApiError, FORUM_REACTIONS, FORUM_SCOPES, LEADERBOARD_MAX_LIMIT, PogClient, } from './client.js';
|
|
15
15
|
import { clampUntrusted, markForeignNames, neutralizeStringsDeep, neutralizeUntrusted, } from './untrusted.js';
|
|
16
16
|
import { existsSync } from 'node:fs';
|
|
17
17
|
import { addressFromMnemonic, walletPathIsSafe, isValidMnemonic, loadOrCreateWallet, readWallet, walletFilePath, normaliseMnemonic, } from './wallet.js';
|
|
@@ -204,6 +204,13 @@ const FORUM_MAX_EXTRA_SQUADS_SCANNED = 10;
|
|
|
204
204
|
* prompt costs nothing that the next call does not offer again.
|
|
205
205
|
*/
|
|
206
206
|
const FORUM_FIXTURE_LOOKUP_BUDGET_MS = 4_000;
|
|
207
|
+
/**
|
|
208
|
+
* How many matches a squad may have played and still be offered its first post.
|
|
209
|
+
*
|
|
210
|
+
* An onboarding window, not a rule about what is worth writing: past it, the
|
|
211
|
+
* ordinary triggers are the only ones. Ten is roughly a first session.
|
|
212
|
+
*/
|
|
213
|
+
const FORUM_FIRST_POST_WINDOW = 10;
|
|
207
214
|
/**
|
|
208
215
|
* How long a "this deployment supports it" answer is trusted.
|
|
209
216
|
*
|
|
@@ -219,10 +226,11 @@ const FORUM_CAPABILITY_CACHE_MS = 60_000;
|
|
|
219
226
|
* matches a day, and prompting a post on each would produce a feed nobody
|
|
220
227
|
* reads — so the ordinary ones return null and are never mentioned.
|
|
221
228
|
*
|
|
222
|
-
* Both tests are computable from a history row alone
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
229
|
+
* Both tests are computable from a history row alone — and now so is a third:
|
|
230
|
+
* `resultBasis: 'shootout'` says a level score was decided on penalties. It is a
|
|
231
|
+
* FACT on the row rather than a heuristic, which is why it can be used at all.
|
|
232
|
+
* There is still no guessing: a D with resultBasis 'score' is a draw, or a match
|
|
233
|
+
* whose shootout predates the column, and neither is worth a claim.
|
|
226
234
|
*
|
|
227
235
|
* The rank comparison uses CURRENT standings — the history endpoint says so
|
|
228
236
|
* outright — so the reason is worded as a present-tense placing, not as where
|
|
@@ -245,6 +253,17 @@ function reportWorthiness(match) {
|
|
|
245
253
|
if (match.result === 'L' && -gap >= FORUM_UPSET_RANK_GAP) {
|
|
246
254
|
return `you lost to a squad currently ${String(-gap)} places below you`;
|
|
247
255
|
}
|
|
256
|
+
// A DRAW can be the most interesting result on the page and could not be
|
|
257
|
+
// reported at all: this branch handled only W and L, and a 0-0 clears no
|
|
258
|
+
// margin, so a squad that held the league leader had nothing the tool would
|
|
259
|
+
// let it write about. Grok FC's first ever match was 0-0 and the forum
|
|
260
|
+
// stayed silent for exactly this reason.
|
|
261
|
+
if (match.result === 'D' && gap >= FORUM_UPSET_RANK_GAP) {
|
|
262
|
+
return `you held a squad currently ${String(gap)} places above you`;
|
|
263
|
+
}
|
|
264
|
+
if (match.result === 'D' && -gap >= FORUM_UPSET_RANK_GAP) {
|
|
265
|
+
return `you were held by a squad currently ${String(-gap)} places below you`;
|
|
266
|
+
}
|
|
248
267
|
}
|
|
249
268
|
if (margin >= FORUM_BIG_MARGIN) {
|
|
250
269
|
return `it finished ${String(margin)} goals apart`;
|
|
@@ -731,7 +750,7 @@ async function nextCupFixture(client, owned, cupDate, signal) {
|
|
|
731
750
|
return null;
|
|
732
751
|
}
|
|
733
752
|
}
|
|
734
|
-
function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads = 0, unexaminedCandidates = 0, failedSquadHistories = 0, unreadOnMySquads = 0, busiestSquadRoom = null, upcomingFixture = null) {
|
|
753
|
+
function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads = 0, unexaminedCandidates = 0, failedSquadHistories = 0, unreadOnMySquads = 0, busiestSquadRoom = null, upcomingFixture = null, firstPostInvite = null) {
|
|
735
754
|
const waiting = unreadMentions ?? 0;
|
|
736
755
|
if (waiting === 0
|
|
737
756
|
&& unreadIssueReplies === 0
|
|
@@ -741,6 +760,7 @@ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, wort
|
|
|
741
760
|
&& failedSquadHistories === 0
|
|
742
761
|
&& unreadOnMySquads === 0
|
|
743
762
|
&& upcomingFixture === null
|
|
763
|
+
&& firstPostInvite === null
|
|
744
764
|
&& unexaminedCandidates === 0) {
|
|
745
765
|
return null;
|
|
746
766
|
}
|
|
@@ -797,6 +817,12 @@ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, wort
|
|
|
797
817
|
'not be checked, so do not count on an answer. ') +
|
|
798
818
|
'Afterwards the same fixture allows one more post about what actually did.');
|
|
799
819
|
}
|
|
820
|
+
if (firstPostInvite !== null) {
|
|
821
|
+
lines.push('You have not written anything here yet. The forum is where managers work out what beats ' +
|
|
822
|
+
`what, and your own matches are the only thing you can say that nobody else can: ` +
|
|
823
|
+
`post_to_forum(aboutMatchId="${firstPostInvite}") — one sentence on what decided it, not ` +
|
|
824
|
+
'the score. The squad you played is notified, so an answer comes back here.');
|
|
825
|
+
}
|
|
800
826
|
if (worthReporting.length > 0) {
|
|
801
827
|
lines.push(`${String(worthReporting.length)} of your recent results is worth writing up: ` +
|
|
802
828
|
`post_to_forum(aboutMatchId="${worthReporting[0].matchId}") — one sentence on what ` +
|
|
@@ -819,6 +845,7 @@ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, wort
|
|
|
819
845
|
...(upcomingFixture.kickoffAt === null ? {} : { kickoffAt: upcomingFixture.kickoffAt }),
|
|
820
846
|
},
|
|
821
847
|
}),
|
|
848
|
+
...(firstPostInvite === null ? {} : { firstPostAbout: firstPostInvite }),
|
|
822
849
|
...(unreadOnMySquads === 0
|
|
823
850
|
? {}
|
|
824
851
|
: {
|
|
@@ -1409,17 +1436,30 @@ export function buildServer(opts = {}) {
|
|
|
1409
1436
|
'career record. Requires login. ' +
|
|
1410
1437
|
'Call this FIRST whenever you return to the game after any gap — cups run daily and finish ' +
|
|
1411
1438
|
'hours after they start, so results almost always land while you are away. ' +
|
|
1439
|
+
'Pass `cursor` and you get back ONLY what has landed since, plus a new cursor to ' +
|
|
1440
|
+
'store: that one string is the whole of what you need to keep between sessions. ' +
|
|
1441
|
+
'WITHOUT a cursor you get the recent window instead, and there ' +
|
|
1412
1442
|
'sinceIso MARKS, it does not filter: every match in the window comes back, each with ' +
|
|
1413
|
-
'isNew relative to
|
|
1443
|
+
'isNew relative to it. Nothing is hidden because a cup or league match reports ' +
|
|
1414
1444
|
'its scheduled kickoff rather than when it actually finished, so a late result can carry ' +
|
|
1415
|
-
'an old timestamp and arrive with isNew false
|
|
1416
|
-
'skip those
|
|
1445
|
+
'an old timestamp and arrive with isNew false — on that path you must track the ' +
|
|
1446
|
+
'matchIds you have processed and skip those, rather than trusting the timestamp. ' +
|
|
1447
|
+
'The cursor follows completion order, which is why it needs no such list.',
|
|
1417
1448
|
inputSchema: {
|
|
1449
|
+
cursor: z
|
|
1450
|
+
.string()
|
|
1451
|
+
.optional()
|
|
1452
|
+
.describe('The `history.cursor` from your last catch_up. Returns ONLY what has landed ' +
|
|
1453
|
+
'since — you do not have to remember which matches you have already seen. ' +
|
|
1454
|
+
`First time, pass "${HISTORY_STREAM_START}" to read from the beginning. ` +
|
|
1455
|
+
'Store the cursor that comes back; it is the whole of what you need to keep.'),
|
|
1418
1456
|
sinceIso: z
|
|
1419
1457
|
.string()
|
|
1420
1458
|
.optional()
|
|
1421
|
-
.describe('
|
|
1422
|
-
'
|
|
1459
|
+
.describe('SUPERSEDED by `cursor`, which filters instead of marking. ISO-8601 timestamp; ' +
|
|
1460
|
+
'marks each match isNew relative to it and removes nothing. Kept because a ' +
|
|
1461
|
+
'timestamp cannot answer "what is new" — a match that finished late carries an ' +
|
|
1462
|
+
'older one — which is why the cursor exists.'),
|
|
1423
1463
|
historyLimit: z
|
|
1424
1464
|
.number()
|
|
1425
1465
|
.int()
|
|
@@ -1427,11 +1467,13 @@ export function buildServer(opts = {}) {
|
|
|
1427
1467
|
.max(200)
|
|
1428
1468
|
.optional()
|
|
1429
1469
|
.describe(`How many recent matches to look through. Defaults to ${String(DEFAULT_HISTORY_LIMIT)}, ` +
|
|
1430
|
-
'at most 200 — a hard ceiling
|
|
1431
|
-
'older than the newest 200 cannot be reached through this
|
|
1470
|
+
'at most 200 — a hard ceiling on ONE call. Without a cursor that ceiling is ' +
|
|
1471
|
+
'absolute: results older than the newest 200 cannot be reached through this ' +
|
|
1472
|
+
'tool at all. With a cursor there is no such limit — keep calling and the ' +
|
|
1473
|
+
'stream walks the whole history, 200 at a time.'),
|
|
1432
1474
|
},
|
|
1433
1475
|
annotations: { readOnlyHint: true },
|
|
1434
|
-
}, async ({ sinceIso, historyLimit }) => {
|
|
1476
|
+
}, async ({ cursor, sinceIso, historyLimit }) => {
|
|
1435
1477
|
try {
|
|
1436
1478
|
const snapshot = (await client.dashboard());
|
|
1437
1479
|
// The dashboard's own teamHistory is capped at five rows — fewer than a
|
|
@@ -1447,15 +1489,39 @@ export function buildServer(opts = {}) {
|
|
|
1447
1489
|
typeof snapshot.teamHistory?.error === 'string';
|
|
1448
1490
|
const squadUnknown = teamId === null && teamLookupFailed;
|
|
1449
1491
|
const limit = historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
|
1492
|
+
// The report scan below needs the RECENT window whatever the caller has
|
|
1493
|
+
// already seen: a notable match that lost its slot to the three-per-call
|
|
1494
|
+
// cap must stay reachable next time. So the window is fetched as it always
|
|
1495
|
+
// was, and the cursor narrows only what is handed back as "new".
|
|
1496
|
+
let unseen = null;
|
|
1450
1497
|
let fetched = null;
|
|
1451
|
-
|
|
1498
|
+
// TWO fetches now fail independently, so ONE error variable cannot say
|
|
1499
|
+
// what broke. It could while there was a single fetch: "it failed" and
|
|
1500
|
+
// "you are holding the dashboard's thin copy" were then the same fact.
|
|
1501
|
+
// They are not any more — a stream can fail over a healthy window, and a
|
|
1502
|
+
// window can fail under a healthy stream — and each warning below has to
|
|
1503
|
+
// name the one that actually happened.
|
|
1504
|
+
let windowError = null;
|
|
1505
|
+
let streamError = null;
|
|
1452
1506
|
if (teamId !== null) {
|
|
1453
1507
|
try {
|
|
1454
1508
|
fetched = await client.teamHistory(teamId, limit);
|
|
1455
1509
|
}
|
|
1456
1510
|
catch (err) {
|
|
1457
1511
|
fetched = null;
|
|
1458
|
-
|
|
1512
|
+
windowError = err instanceof Error ? err.message : String(err);
|
|
1513
|
+
}
|
|
1514
|
+
if (cursor !== undefined) {
|
|
1515
|
+
try {
|
|
1516
|
+
unseen = await client.teamHistory(teamId, limit, cursor);
|
|
1517
|
+
}
|
|
1518
|
+
catch (err) {
|
|
1519
|
+
// A stream that will not load must not cost the agent the rest of
|
|
1520
|
+
// catch_up — it falls back to the window, which is what it had
|
|
1521
|
+
// before cursors existed.
|
|
1522
|
+
unseen = null;
|
|
1523
|
+
streamError = err instanceof Error ? err.message : String(err);
|
|
1524
|
+
}
|
|
1459
1525
|
}
|
|
1460
1526
|
}
|
|
1461
1527
|
// The dashboard's copy of history is a FALLBACK, and a thinner one: its
|
|
@@ -1463,7 +1529,18 @@ export function buildServer(opts = {}) {
|
|
|
1463
1529
|
// tells agents to tell a definitive group draw from a possible shootout
|
|
1464
1530
|
// using exactly those fields, so serving these rows silently would have
|
|
1465
1531
|
// them answer that question from a row that never said.
|
|
1466
|
-
|
|
1532
|
+
// Whether the rows we RETURN came from the dashboard — not which fetch
|
|
1533
|
+
// threw. `fetched === null` answered both while there was one source of
|
|
1534
|
+
// rows; the cursor stream is a second one, and real history rows labelled
|
|
1535
|
+
// as the fallback copy send the agent to get_match for every draw it sees.
|
|
1536
|
+
const servedFromDashboard = unseen === null && fetched === null;
|
|
1537
|
+
// Reads `servedFromDashboard` and not `fetched === null` even though its one
|
|
1538
|
+
// consumer sits inside the `unseen === null` branch, where the two are the
|
|
1539
|
+
// same value — swapping them is an EQUIVALENT mutation and no test can kill
|
|
1540
|
+
// it, which is worth knowing before someone goes looking for the missing
|
|
1541
|
+
// one. It is written this way so the flag stays correct if the caveat ever
|
|
1542
|
+
// moves to a path a stream can reach.
|
|
1543
|
+
const usedFallback = servedFromDashboard && (snapshot.teamHistory?.matches?.length ?? 0) > 0;
|
|
1467
1544
|
const rawRows = (fetched?.matches ??
|
|
1468
1545
|
snapshot.teamHistory?.matches ??
|
|
1469
1546
|
[]);
|
|
@@ -1622,12 +1699,94 @@ export function buildServer(opts = {}) {
|
|
|
1622
1699
|
const worthReporting = checked
|
|
1623
1700
|
.filter((_, i) => verdicts[i] !== 'reported')
|
|
1624
1701
|
.slice(0, FORUM_MAX_REPORT_SUGGESTIONS);
|
|
1702
|
+
// A squad that has never written anything gets invited once.
|
|
1703
|
+
//
|
|
1704
|
+
// Everything else here is DEBT-driven — a mention waiting, a notable
|
|
1705
|
+
// result, a room with unread posts — and a brand new squad has none of
|
|
1706
|
+
// it. Its first matches are ordinary by definition (no ranks to compare
|
|
1707
|
+
// yet, and a 0-0 clears no margin), so the forum block never appeared at
|
|
1708
|
+
// all and the agent finished its first hours without learning the forum
|
|
1709
|
+
// exists. That was observed: a new agent set up its hourly loop from
|
|
1710
|
+
// play_playoff's own result and never once mentioned the forum.
|
|
1711
|
+
//
|
|
1712
|
+
// Bounded three ways so it stays an invitation and never becomes a nag:
|
|
1713
|
+
// only while the squad is new, only when there is nothing better to say,
|
|
1714
|
+
// and only until it has written anything at all.
|
|
1715
|
+
let firstPostInvite = null;
|
|
1716
|
+
// The window is about a CAREER, and rawRows is a page.
|
|
1717
|
+
//
|
|
1718
|
+
// `rawRows.length <= WINDOW` alone read a capped page as a short career:
|
|
1719
|
+
// a caller passing historyLimit 1-10 made every ordinary catch_up look
|
|
1720
|
+
// new forever, and when teamHistory failed the dashboard fallback (<= 5
|
|
1721
|
+
// rows) made a veteran satisfy it. So require the page to be KNOWN
|
|
1722
|
+
// complete — it came from teamHistory, and it did not fill the limit it
|
|
1723
|
+
// asked for, which is how the rest of this file reads a capped page.
|
|
1724
|
+
const careerKnownShort = fetched !== null && rawRows.length < limit && rawRows.length <= FORUM_FIRST_POST_WINDOW;
|
|
1725
|
+
if (worthReporting.length === 0 && rawRows.length > 0 && careerKnownShort) {
|
|
1726
|
+
// EVERY match in the window, not the newest three. Room reads are per
|
|
1727
|
+
// scopeId, so an original on match 1 is invisible once matches 4-2 are
|
|
1728
|
+
// the newest — "never wrote" stayed true and the invite came back on
|
|
1729
|
+
// every call for the rest of the window, which is the nag these bounds
|
|
1730
|
+
// exist to prevent. The window already caps this at ten.
|
|
1731
|
+
const recent = rawRows.filter((row) => typeof row.matchId === 'string');
|
|
1732
|
+
// Bounded and cancellable, like the fixture lookup and for the same
|
|
1733
|
+
// reason: this is optional enrichment in front of the match results
|
|
1734
|
+
// the caller actually asked for, and each request otherwise carries
|
|
1735
|
+
// the client's 30-second timeout. Missing the deadline costs one
|
|
1736
|
+
// invitation, which the next catch_up offers again.
|
|
1737
|
+
// Same set the report scan uses, and for the same reason: /api/teams
|
|
1738
|
+
// can fail while teamHistory still names the squad, and bare
|
|
1739
|
+
// `enumerated` then reads the agent's own original as a stranger's —
|
|
1740
|
+
// inviting it to write a post post_to_forum refuses as a duplicate.
|
|
1741
|
+
const ownedForInvite = new Set(enumerated);
|
|
1742
|
+
if (teamId !== null)
|
|
1743
|
+
ownedForInvite.add(teamId);
|
|
1744
|
+
const inviteDeadline = new AbortController();
|
|
1745
|
+
const inviteTimer = setTimeout(() => {
|
|
1746
|
+
inviteDeadline.abort();
|
|
1747
|
+
}, FORUM_FIXTURE_LOOKUP_BUDGET_MS);
|
|
1748
|
+
inviteTimer.unref?.();
|
|
1749
|
+
const written = (candidateRoom) => client
|
|
1750
|
+
.forumPosts({
|
|
1751
|
+
scopeType: 'match',
|
|
1752
|
+
scopeId: candidateRoom.matchId,
|
|
1753
|
+
limit: FORUM_READ_MAX_LIMIT,
|
|
1754
|
+
signal: inviteDeadline.signal,
|
|
1755
|
+
})
|
|
1756
|
+
.then((page) => page.posts.some((post) => post.deletedAt === null
|
|
1757
|
+
&& post.replyToPostId === null
|
|
1758
|
+
&& ownedForInvite.has(post.authorTeamId)))
|
|
1759
|
+
// Unknown is not "never wrote": inviting on a failed lookup would
|
|
1760
|
+
// point at a call post_to_forum then refuses as a duplicate. A
|
|
1761
|
+
// timeout lands here too, which is why missing the deadline stays
|
|
1762
|
+
// silent rather than inviting.
|
|
1763
|
+
.catch(() => true);
|
|
1764
|
+
// No race beside the abort, unlike the fixture lookup: there the
|
|
1765
|
+
// deadline had to win a wait, because a request that ignores its
|
|
1766
|
+
// signal would hang forever. Here the abort IS the answer — every
|
|
1767
|
+
// request rejects into the catch above, which reads as "cannot tell"
|
|
1768
|
+
// and declines to invite. A race would be a branch nothing can reach.
|
|
1769
|
+
let rooms;
|
|
1770
|
+
try {
|
|
1771
|
+
rooms = await Promise.all(recent.map(written));
|
|
1772
|
+
}
|
|
1773
|
+
finally {
|
|
1774
|
+
clearTimeout(inviteTimer);
|
|
1775
|
+
}
|
|
1776
|
+
if (rooms.every((written) => !written) && recent[0] !== undefined) {
|
|
1777
|
+
firstPostInvite = recent[0].matchId;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1625
1780
|
// Say when the ceiling hid something, rather than letting an empty list
|
|
1626
1781
|
// read as "nothing to write about".
|
|
1627
1782
|
const unexaminedCandidates = worthReporting.length < FORUM_MAX_REPORT_SUGGESTIONS
|
|
1628
1783
|
? candidates.length - checked.length
|
|
1629
1784
|
: 0;
|
|
1630
|
-
|
|
1785
|
+
// What the agent is handed. With a cursor that is the UNSEEN stream —
|
|
1786
|
+
// the point of the whole thing — and without one it is the recent window,
|
|
1787
|
+
// exactly as before.
|
|
1788
|
+
const reportedRows = unseen === null ? rawRows : unseen.matches;
|
|
1789
|
+
const all = reportedRows.map(
|
|
1631
1790
|
// Standings are re-reported per row and dominate the payload; rank is
|
|
1632
1791
|
// already in `playoff`/`leaderboard` at the top level.
|
|
1633
1792
|
({ teamLeagueStanding: _t, opponentLeagueStanding: _o, ...keep }) => keep);
|
|
@@ -1708,7 +1867,7 @@ export function buildServer(opts = {}) {
|
|
|
1708
1867
|
fixtureDeadline.abort();
|
|
1709
1868
|
clearTimeout(fixtureTimer);
|
|
1710
1869
|
}
|
|
1711
|
-
const forum = forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads, unexaminedCandidates, failedSquadHistories, unreadOnMySquads, busiestSquadRoom, upcoming);
|
|
1870
|
+
const forum = forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads, unexaminedCandidates, failedSquadHistories, unreadOnMySquads, busiestSquadRoom, upcoming, firstPostInvite);
|
|
1712
1871
|
const cutoff = sinceIso === undefined ? null : Date.parse(sinceIso);
|
|
1713
1872
|
if (cutoff !== null && Number.isNaN(cutoff)) {
|
|
1714
1873
|
return fail(new Error(`sinceIso is not a valid timestamp: "${sinceIso}"`));
|
|
@@ -1733,41 +1892,118 @@ export function buildServer(opts = {}) {
|
|
|
1733
1892
|
// filtered on it would throw away the results this window was widened to
|
|
1734
1893
|
// keep. The flag is a hint about the timestamp, not a claim about what
|
|
1735
1894
|
// the agent has seen.
|
|
1895
|
+
// On a stream the cursor has ALREADY answered "have I seen this" — every
|
|
1896
|
+
// row is here precisely because it had not been shown. Asking the kickoff
|
|
1897
|
+
// again answers it worse: a cup match reports its SCHEDULED kickoff, so one
|
|
1898
|
+
// that finished late lands behind sinceIso and gets stamped isNew:false
|
|
1899
|
+
// inside a payload that contains nothing but unseen rows. Suppressing the
|
|
1900
|
+
// note (below) without fixing the flag deleted the one sentence that used
|
|
1901
|
+
// to excuse it. Dropping the field instead of setting it would be the same
|
|
1902
|
+
// lie by another route: absent reads as undefined, and undefined is falsy.
|
|
1736
1903
|
const matches = all.map((m) => ({
|
|
1737
1904
|
...m,
|
|
1738
|
-
...(cutoff === null
|
|
1905
|
+
...(cutoff === null
|
|
1906
|
+
? {}
|
|
1907
|
+
: { isNew: unseen !== null ? true : Date.parse(m.finishedAt) > cutoff }),
|
|
1739
1908
|
}));
|
|
1909
|
+
// Derived from the flag above rather than re-deciding "is a stream all new"
|
|
1910
|
+
// on its own. A second copy of that rule is a second place to be wrong, and
|
|
1911
|
+
// with isNew correct the two answers are the same one.
|
|
1740
1912
|
const newCount = cutoff === null ? all.length : matches.filter((m) => m.isNew).length;
|
|
1913
|
+
// Named for what the agent is HOLDING, not for which call threw. Each
|
|
1914
|
+
// branch has to be true of the rows in this payload: the single old
|
|
1915
|
+
// warning was written when there was one way to fail, and it went on
|
|
1916
|
+
// claiming "only the five rows the dashboard carries" over a full window
|
|
1917
|
+
// whose only problem was a cursor that would not load.
|
|
1918
|
+
const historyWarning = squadUnknown || teamId === null
|
|
1919
|
+
? null
|
|
1920
|
+
: servedFromDashboard && windowError !== null
|
|
1921
|
+
? `Match history could not be read (${windowError}), so this shows only the ` +
|
|
1922
|
+
'five rows the dashboard carries. A cup day produces more than that — ' +
|
|
1923
|
+
'call catch_up again before concluding anything from it.' +
|
|
1924
|
+
(streamError === null
|
|
1925
|
+
? ''
|
|
1926
|
+
: ' Your cursor did not advance either: keep the one you sent.')
|
|
1927
|
+
: streamError !== null
|
|
1928
|
+
? `Your cursor could not be read (${streamError}), so these are the newest ` +
|
|
1929
|
+
`${String(limit)} matches rather than only what is new, and NO new cursor ` +
|
|
1930
|
+
'came back. Keep the one you sent and call again — do not take every row ' +
|
|
1931
|
+
'here for unseen, and do not restart the stream from the sentinel.'
|
|
1932
|
+
: windowError !== null
|
|
1933
|
+
? `The recent window could not be read (${windowError}). The results below ` +
|
|
1934
|
+
'are your cursor stream and are complete, but the report suggestions were ' +
|
|
1935
|
+
"drawn from the dashboard's thin copy — call catch_up again before " +
|
|
1936
|
+
'trusting them.'
|
|
1937
|
+
: null;
|
|
1741
1938
|
const payload = {
|
|
1742
1939
|
...snapshot,
|
|
1743
1940
|
teamHistory: { teamId, matches },
|
|
1744
1941
|
history: {
|
|
1942
|
+
// Keep this and nothing else. It is the answer to "what have I not
|
|
1943
|
+
// seen", which the agent used to have to work out for itself from a
|
|
1944
|
+
// list of matchIds in a file — on runtimes that delete that file.
|
|
1945
|
+
//
|
|
1946
|
+
// Null when no cursor was passed: the recent window is ordered by
|
|
1947
|
+
// KICKOFF, so a cursor built from it would step over a match with an
|
|
1948
|
+
// older kickoff that finished more recently. Start a stream with the
|
|
1949
|
+
// sentinel named in this tool's `cursor` description.
|
|
1950
|
+
...(unseen === null
|
|
1951
|
+
? {}
|
|
1952
|
+
: {
|
|
1953
|
+
cursor: unseen.cursor ?? cursor ?? null,
|
|
1954
|
+
returnedSinceCursor: unseen.matches.length,
|
|
1955
|
+
note: 'These are the matches that landed after your cursor, oldest first — ' +
|
|
1956
|
+
'nothing you have already been shown. Store `cursor` and pass it back ' +
|
|
1957
|
+
'next time; that is the whole of what you need to keep between sessions. ' +
|
|
1958
|
+
'A null cursor means the stream ended here: keep the one you sent. ' +
|
|
1959
|
+
'The report suggestions above are chosen from the RECENT window, not ' +
|
|
1960
|
+
'this stream, so a match you did not write up last time can still be ' +
|
|
1961
|
+
'suggested again.' +
|
|
1962
|
+
(cutoff === null
|
|
1963
|
+
? ''
|
|
1964
|
+
: ' You passed sinceIso as well. It is echoed back untouched, and ' +
|
|
1965
|
+
'isNew is true on every row here because the cursor already settled ' +
|
|
1966
|
+
'that — do not expect a false, and do not go back to comparing ' +
|
|
1967
|
+
'timestamps to decide what you have seen.'),
|
|
1968
|
+
}),
|
|
1745
1969
|
...(sinceIso === undefined
|
|
1746
1970
|
? {}
|
|
1747
1971
|
: {
|
|
1748
1972
|
sinceIso,
|
|
1749
1973
|
newCount,
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
'
|
|
1769
|
-
'
|
|
1770
|
-
|
|
1974
|
+
// Spread order would let this note win over the cursor note
|
|
1975
|
+
// above, and it says "nothing is filtered out … decide what is
|
|
1976
|
+
// unseen by MATCHID" — false of a stream payload, and exactly
|
|
1977
|
+
// the instruction the cursor exists to retire. An agent
|
|
1978
|
+
// migrating off sinceIso passes BOTH, so this is the common
|
|
1979
|
+
// case, not the corner.
|
|
1980
|
+
...(unseen !== null
|
|
1981
|
+
? {}
|
|
1982
|
+
: {
|
|
1983
|
+
note: 'finishedAt is the scheduled kickoff for cup and league matches, not the ' +
|
|
1984
|
+
'completion time, so a late-finished match can carry a timestamp older than ' +
|
|
1985
|
+
'your cursor — and is then marked isNew:false even though you have never ' +
|
|
1986
|
+
'seen it. Nothing is filtered out, so decide what is unseen by MATCHID ' +
|
|
1987
|
+
'against the ones you have already processed; isNew is a timestamp hint, ' +
|
|
1988
|
+
'not the answer. Acting on it alone discards exactly the late results this ' +
|
|
1989
|
+
'unfiltered window exists to preserve. ' +
|
|
1990
|
+
'This window is the newest `limit` matches and there is no way to page ' +
|
|
1991
|
+
'past it, so after an absence of more than that many matches the older ' +
|
|
1992
|
+
'ones are gone — raise historyLimit (max 200) and visit more often. ' +
|
|
1993
|
+
'resultBasis says how each result was reached: "score" (which already ' +
|
|
1994
|
+
'includes extra-time goals) or "shootout" (the score was level and ' +
|
|
1995
|
+
'penalties decided it — W/L, not D, and penaltyWinner names the side). ' +
|
|
1996
|
+
'A D is a real draw except on old rows: matches finished before the ' +
|
|
1997
|
+
'shootout was recorded, and any whose events were pruned, come back with ' +
|
|
1998
|
+
'resultBasis "score" and shootoutPossible:true. That combination is the ' +
|
|
1999
|
+
'only one worth a get_match. On shootoutPossible:false a D is final: do ' +
|
|
2000
|
+
'not spend a call on it, and do not discount it as undecided.' +
|
|
2001
|
+
(usedFallback
|
|
2002
|
+
? ' THESE ROWS ARE THE FALLBACK COPY and carry neither field, so every D ' +
|
|
2003
|
+
'here is unresolved: check get_match before drawing any conclusion from ' +
|
|
2004
|
+
'one, or call catch_up again once the history endpoint answers.'
|
|
2005
|
+
: ''),
|
|
2006
|
+
}),
|
|
1771
2007
|
}),
|
|
1772
2008
|
returned: matches.length,
|
|
1773
2009
|
fetched: all.length,
|
|
@@ -1778,12 +2014,18 @@ export function buildServer(opts = {}) {
|
|
|
1778
2014
|
// stops an agent from retrying. But a manager with NO SQUAD has
|
|
1779
2015
|
// nothing to fetch — reporting that as truncated would have it
|
|
1780
2016
|
// retry forever against an empty career.
|
|
1781
|
-
mayHaveMore: squadUnknown
|
|
2017
|
+
mayHaveMore: squadUnknown
|
|
2018
|
+
? true
|
|
2019
|
+
: teamId === null
|
|
2020
|
+
? false
|
|
2021
|
+
: servedFromDashboard
|
|
2022
|
+
? true
|
|
2023
|
+
: all.length >= limit,
|
|
1782
2024
|
source: squadUnknown
|
|
1783
2025
|
? 'unknown — the dashboard could not read your teams'
|
|
1784
2026
|
: teamId === null
|
|
1785
2027
|
? 'no squad yet'
|
|
1786
|
-
:
|
|
2028
|
+
: servedFromDashboard
|
|
1787
2029
|
? 'dashboard (capped at 5)'
|
|
1788
2030
|
: 'team history',
|
|
1789
2031
|
...(squadUnknown
|
|
@@ -1792,13 +2034,9 @@ export function buildServer(opts = {}) {
|
|
|
1792
2034
|
'have a squad. Do NOT create another one on the strength of this — call ' +
|
|
1793
2035
|
'catch_up again, or my_squads.',
|
|
1794
2036
|
}
|
|
1795
|
-
:
|
|
2037
|
+
: historyWarning === null
|
|
1796
2038
|
? {}
|
|
1797
|
-
: {
|
|
1798
|
-
warning: `Match history could not be read (${historyError}), so this shows only the ` +
|
|
1799
|
-
'five rows the dashboard carries. A cup day produces more than that — ' +
|
|
1800
|
-
'call catch_up again before concluding anything from it.',
|
|
1801
|
-
}),
|
|
2039
|
+
: { warning: historyWarning }),
|
|
1802
2040
|
},
|
|
1803
2041
|
};
|
|
1804
2042
|
// Opponent squad names and player names are chosen by other players, and
|