@witchpot/gmc 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/package.json +1 -1
- package/src/args.js +8 -0
- package/src/constants.js +10 -2
- package/src/help.js +69 -0
- package/src/requests.js +159 -0
- package/src/requests.test.js +171 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Game Market Copilot CLI will be recorded here.
|
|
4
4
|
|
|
5
|
+
## 0.10.0 - 2026-09-22
|
|
6
|
+
|
|
7
|
+
- New `gmc roblox` command family (NAK-715, Provisional): `charts` (one
|
|
8
|
+
recorded ranking cohort as of a cutoff), `experiences` (the tracked
|
|
9
|
+
catalog with filters, sorts and signed-cursor pagination), `experience
|
|
10
|
+
<universeId>` (one experience with per-metric observation times),
|
|
11
|
+
`history <universeId> --granularity hourly|daily` (retained observations
|
|
12
|
+
with explicit gaps), and `coverage` (the configured cohort matrix with
|
|
13
|
+
per-dimension freshness). Universe ids are decimal strings end to end —
|
|
14
|
+
they are never Steam appids, and the commands reject one. The Roblox
|
|
15
|
+
surface went live in production on 2026-09-22 (NAK-725); the commands
|
|
16
|
+
stay Provisional until the surface has been through a broader real-data
|
|
17
|
+
smoke period.
|
|
18
|
+
|
|
19
|
+
- The Roblox read endpoints reject impossible calendar dates in every
|
|
20
|
+
offset spelling (`as_of=2026-02-31T12:00:00+09:00` is now a 400 naming
|
|
21
|
+
the nonexistent date instead of silently answering for March 3), and
|
|
22
|
+
history gap counts always reconcile with the expected point count.
|
|
23
|
+
|
|
24
|
+
- Not yet mapped: the saved-experiences surface (`/api/v1/roblox/saved`,
|
|
25
|
+
NAK-723) is live over REST and MCP; a CLI mapping will follow.
|
|
26
|
+
|
|
5
27
|
## 0.9.0 - 2026-09-16
|
|
6
28
|
|
|
7
29
|
- New `--release-status <released|upcoming|tba>` filter on the games
|
package/package.json
CHANGED
package/src/args.js
CHANGED
|
@@ -90,6 +90,11 @@ function isCommandToken(command, token) {
|
|
|
90
90
|
// unknown command `steam` with a stray positional rather than reaching
|
|
91
91
|
// the tag-trends branch.
|
|
92
92
|
"steam",
|
|
93
|
+
// NAK-715. Same trap as `steam` above: a token missing from this list
|
|
94
|
+
// falls through to positional rather than erroring, so `gmc roblox
|
|
95
|
+
// charts` would parse as the unknown command `roblox` with a stray
|
|
96
|
+
// positional instead of reaching the charts branch.
|
|
97
|
+
"roblox",
|
|
93
98
|
"auth",
|
|
94
99
|
"login",
|
|
95
100
|
"logout",
|
|
@@ -138,6 +143,9 @@ function isCommandToken(command, token) {
|
|
|
138
143
|
if (command[0] === "steam") {
|
|
139
144
|
return ["tag-trends", "trend-stories"].includes(token);
|
|
140
145
|
}
|
|
146
|
+
if (command[0] === "roblox") {
|
|
147
|
+
return ["charts", "experiences", "experience", "history", "coverage"].includes(token);
|
|
148
|
+
}
|
|
141
149
|
return false;
|
|
142
150
|
}
|
|
143
151
|
|
package/src/constants.js
CHANGED
|
@@ -7,7 +7,7 @@ export const EXIT_CONFIG = 3;
|
|
|
7
7
|
export const EXIT_API = 4;
|
|
8
8
|
|
|
9
9
|
export const CLI_PACKAGE_NAME = "@witchpot/gmc";
|
|
10
|
-
export const CLI_VERSION = "0.
|
|
10
|
+
export const CLI_VERSION = "0.10.0";
|
|
11
11
|
|
|
12
12
|
export const DEFAULT_API_BASE_URL = "https://gamemarketcopilot.com";
|
|
13
13
|
export const DEFAULT_CONFIG_PATH = path.join(homedir(), ".config", "gmc", "config.json");
|
|
@@ -63,7 +63,15 @@ export const PROVISIONAL_BETA_COMMANDS = [
|
|
|
63
63
|
"gmc steam tag-trends --view featured|board|group|table|tag --json",
|
|
64
64
|
// NAK-804. New command; starts Provisional per the closed-beta roadmap's
|
|
65
65
|
// default for anything not yet through broader real-data smoke testing.
|
|
66
|
-
"gmc games ai-disclosure <appid> --json"
|
|
66
|
+
"gmc games ai-disclosure <appid> --json",
|
|
67
|
+
// NAK-715/NAK-725. Enabled in production on 2026-09-22; stays Provisional
|
|
68
|
+
// per the closed-beta roadmap's default until the surface has been through
|
|
69
|
+
// a broader real-data smoke period (the NAK-804 rule), then graduates.
|
|
70
|
+
"gmc roblox charts --country all --sort top-playing-now --json",
|
|
71
|
+
"gmc roblox experiences --country all --limit 50 --json",
|
|
72
|
+
"gmc roblox experience <universeId> --json",
|
|
73
|
+
"gmc roblox history <universeId> --granularity daily|hourly --json",
|
|
74
|
+
"gmc roblox coverage --json"
|
|
67
75
|
];
|
|
68
76
|
|
|
69
77
|
export const COMPARE_FOCUSES = ["overview", "market", "reviews", "marketing", "audience", "showcases"];
|
package/src/help.js
CHANGED
|
@@ -70,6 +70,11 @@ Usage:
|
|
|
70
70
|
gmc steam tag-trends --view featured --json
|
|
71
71
|
gmc steam tag-trends --view table --sort release_share_delta --order desc --json
|
|
72
72
|
gmc steam tag-trends --view tag --tag <tag_slug> --json
|
|
73
|
+
gmc roblox charts --country all --sort top-playing-now --json
|
|
74
|
+
gmc roblox experiences --country all --limit 50 --json
|
|
75
|
+
gmc roblox experience <universeId> --json
|
|
76
|
+
gmc roblox history <universeId> --granularity daily --json
|
|
77
|
+
gmc roblox coverage --json
|
|
73
78
|
|
|
74
79
|
Tag taxonomy:
|
|
75
80
|
gmc tags --json List the full tag dictionary (slug, name).
|
|
@@ -232,6 +237,70 @@ Steam tag trends:
|
|
|
232
237
|
gmc steam tag-trends --view table --sort current_share --limit 50 --json
|
|
233
238
|
gmc steam tag-trends --view tag --tag cozy --json
|
|
234
239
|
|
|
240
|
+
Roblox:
|
|
241
|
+
gmc roblox charts --json One ranking cohort's stored chart
|
|
242
|
+
snapshot as of a cutoff.
|
|
243
|
+
gmc roblox experiences --json Browse the tracked cohort with coverage.
|
|
244
|
+
gmc roblox experience <universeId> --json
|
|
245
|
+
One experience: metadata, metrics,
|
|
246
|
+
artwork.
|
|
247
|
+
gmc roblox history <universeId> --json
|
|
248
|
+
A bounded window of stored metric
|
|
249
|
+
observations.
|
|
250
|
+
gmc roblox coverage --json What the surface has, per dimension.
|
|
251
|
+
--country <country> all | US | JP. Selects which CHART was
|
|
252
|
+
read, never a regional metric scope.
|
|
253
|
+
--device <device> computer.
|
|
254
|
+
--sort <sort> top-playing-now | top-trending |
|
|
255
|
+
top-revisited | fun-with-friends.
|
|
256
|
+
--as-of <instant> ISO 8601 cutoff; defaults to now. No
|
|
257
|
+
observation after it is considered.
|
|
258
|
+
--eligibility-days <number> experiences only, 1-30 (default 7). How
|
|
259
|
+
far back a chart appearance still counts.
|
|
260
|
+
--min-player-count <number> experiences only, on the resolved row.
|
|
261
|
+
--max-rank <number> experiences only, on the resolved row.
|
|
262
|
+
--genre <genre> experiences only, exact match.
|
|
263
|
+
--creator-type <type> experiences only, e.g. Group or User.
|
|
264
|
+
--detail <enriched|chart_only> experiences only. Omitted returns both.
|
|
265
|
+
--order-by <key> rank | player_count | last_chart_seen |
|
|
266
|
+
universe_id (experiences only).
|
|
267
|
+
--order <asc|desc> experiences only.
|
|
268
|
+
--granularity <hourly|daily> history only. Defaults to daily.
|
|
269
|
+
--from <instant> --to <instant> history only. Hourly windows span at most
|
|
270
|
+
90 days, daily at most 365.
|
|
271
|
+
--limit <number> charts 1-250, experiences 1-200.
|
|
272
|
+
--offset <number> charts only.
|
|
273
|
+
--cursor <cursor> experiences only. Continuation token.
|
|
274
|
+
|
|
275
|
+
A Roblox universe id is NOT a Steam appid. Ids travel as decimal strings
|
|
276
|
+
because they exceed the range a JSON number represents exactly, and nothing
|
|
277
|
+
here merges a Roblox experience with a Steam game.
|
|
278
|
+
|
|
279
|
+
Unknown is never zero. A null player count was not measured. price.known
|
|
280
|
+
false means the price is UNKNOWN, not that the experience is free. Zero
|
|
281
|
+
recorded votes gives no positive percentage at all, rather than 0%.
|
|
282
|
+
|
|
283
|
+
Nothing is fabricated to fill a gap. An absent cohort answers availability
|
|
284
|
+
"never_observed" with an empty entries list — a measurement about
|
|
285
|
+
collection, not an error and never another sort's ranking. History reports
|
|
286
|
+
unsampled periods in data.gaps and interpolates nothing, so a gap is not a
|
|
287
|
+
zero. Daily sampledPlayingMin/Max are the extremes of the SAMPLES, not the
|
|
288
|
+
day's peak; compare CCU only across points whose comparable is true.
|
|
289
|
+
Observed counter decreases are reported as-is and are not resets.
|
|
290
|
+
|
|
291
|
+
This is a curated tracked cohort, never all of Roblox. Quote
|
|
292
|
+
coverage.trackedUniverses as the denominator for any share.
|
|
293
|
+
|
|
294
|
+
While the surface is switched off the commands answer 404
|
|
295
|
+
ROBLOX_UNAVAILABLE. That is the documented not-available state, not an
|
|
296
|
+
outage and not worth retrying on a schedule.
|
|
297
|
+
|
|
298
|
+
Examples:
|
|
299
|
+
gmc roblox coverage --json
|
|
300
|
+
gmc roblox charts --country JP --sort top-trending --json
|
|
301
|
+
gmc roblox experiences --min-player-count 1000 --order-by player_count --json
|
|
302
|
+
gmc roblox history <universeId> --granularity hourly --json
|
|
303
|
+
|
|
235
304
|
Binary aliases:
|
|
236
305
|
gmcp and game-market-copilot run the same CLI. Use them when a shell
|
|
237
306
|
alias shadows gmc (Oh My Zsh's git plugin defines gmc as
|
package/src/requests.js
CHANGED
|
@@ -650,6 +650,137 @@ export function buildRequest(command, options, positional) {
|
|
|
650
650
|
};
|
|
651
651
|
}
|
|
652
652
|
|
|
653
|
+
// NAK-715. Thin pass-throughs to the Roblox read endpoints, on the same rule
|
|
654
|
+
// the tag-trends branch above documents: the CLI forwards the flags it was
|
|
655
|
+
// given and the SERVER owns the contract — the per-endpoint allowlists, the
|
|
656
|
+
// enum members, the limit ceilings, the window bounds and the cursor
|
|
657
|
+
// binding. `assertSupportedOptions` still runs for the narrower thing it is
|
|
658
|
+
// for: a flag the endpoint has no parameter for would otherwise be dropped
|
|
659
|
+
// silently and answer the unnarrowed question with exit 0.
|
|
660
|
+
//
|
|
661
|
+
// A Roblox universe id is NOT a Steam appid and is never sent as a number.
|
|
662
|
+
// It travels as the decimal string the user typed, because these ids exceed
|
|
663
|
+
// the range a JS number represents exactly.
|
|
664
|
+
if (matches(command, ["roblox", "charts"])) {
|
|
665
|
+
assertSupportedOptions(options, {
|
|
666
|
+
command: "gmc roblox charts",
|
|
667
|
+
allowed: ["country", "device", "sort", "as-of", "limit", "offset"]
|
|
668
|
+
});
|
|
669
|
+
assertNoExtraPositionals(positional, {
|
|
670
|
+
expected: 0,
|
|
671
|
+
usage: "gmc roblox charts --country all --sort top-playing-now --json"
|
|
672
|
+
});
|
|
673
|
+
return {
|
|
674
|
+
path: "/api/v1/roblox/charts",
|
|
675
|
+
// Every value is omitted when unset so the server owns each default and
|
|
676
|
+
// no default can drift into a copy kept here.
|
|
677
|
+
query: pickQuery(options, {
|
|
678
|
+
country: "country",
|
|
679
|
+
device: "device",
|
|
680
|
+
sort: "sort",
|
|
681
|
+
"as-of": "as_of",
|
|
682
|
+
limit: "limit",
|
|
683
|
+
offset: "offset"
|
|
684
|
+
})
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (matches(command, ["roblox", "experiences"])) {
|
|
689
|
+
assertSupportedOptions(options, {
|
|
690
|
+
command: "gmc roblox experiences",
|
|
691
|
+
allowed: [
|
|
692
|
+
"country",
|
|
693
|
+
"device",
|
|
694
|
+
"sort",
|
|
695
|
+
"as-of",
|
|
696
|
+
"eligibility-days",
|
|
697
|
+
"min-player-count",
|
|
698
|
+
"max-rank",
|
|
699
|
+
"genre",
|
|
700
|
+
"name",
|
|
701
|
+
"creator-type",
|
|
702
|
+
"detail",
|
|
703
|
+
"order-by",
|
|
704
|
+
"order",
|
|
705
|
+
"limit",
|
|
706
|
+
"cursor"
|
|
707
|
+
]
|
|
708
|
+
});
|
|
709
|
+
assertNoExtraPositionals(positional, {
|
|
710
|
+
expected: 0,
|
|
711
|
+
usage: "gmc roblox experiences --country all --limit 50 --json"
|
|
712
|
+
});
|
|
713
|
+
return {
|
|
714
|
+
path: "/api/v1/roblox/experiences",
|
|
715
|
+
query: pickQuery(options, {
|
|
716
|
+
country: "country",
|
|
717
|
+
device: "device",
|
|
718
|
+
sort: "sort",
|
|
719
|
+
"as-of": "as_of",
|
|
720
|
+
"eligibility-days": "eligibility_days",
|
|
721
|
+
"min-player-count": "min_player_count",
|
|
722
|
+
"max-rank": "max_rank",
|
|
723
|
+
genre: "genre",
|
|
724
|
+
name: "name",
|
|
725
|
+
"creator-type": "creator_type",
|
|
726
|
+
detail: "detail",
|
|
727
|
+
"order-by": "order_by",
|
|
728
|
+
order: "order",
|
|
729
|
+
limit: "limit",
|
|
730
|
+
cursor: "cursor"
|
|
731
|
+
})
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (matches(command, ["roblox", "experience"])) {
|
|
736
|
+
assertSupportedOptions(options, { command: "gmc roblox experience", allowed: ["as-of"] });
|
|
737
|
+
const universeId = assertUniverseId(
|
|
738
|
+
positional[0],
|
|
739
|
+
"gmc roblox experience <universeId> --json"
|
|
740
|
+
);
|
|
741
|
+
assertNoExtraPositionals(positional, {
|
|
742
|
+
expected: 1,
|
|
743
|
+
usage: "gmc roblox experience <universeId> --json"
|
|
744
|
+
});
|
|
745
|
+
return {
|
|
746
|
+
path: `/api/v1/roblox/experiences/${encodeURIComponent(universeId)}`,
|
|
747
|
+
query: pickQuery(options, { "as-of": "as_of" })
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (matches(command, ["roblox", "history"])) {
|
|
752
|
+
assertSupportedOptions(options, {
|
|
753
|
+
command: "gmc roblox history",
|
|
754
|
+
allowed: ["granularity", "from", "to", "as-of"]
|
|
755
|
+
});
|
|
756
|
+
const universeId = assertUniverseId(
|
|
757
|
+
positional[0],
|
|
758
|
+
"gmc roblox history <universeId> --granularity daily --json"
|
|
759
|
+
);
|
|
760
|
+
assertNoExtraPositionals(positional, {
|
|
761
|
+
expected: 1,
|
|
762
|
+
usage: "gmc roblox history <universeId> --granularity daily --json"
|
|
763
|
+
});
|
|
764
|
+
return {
|
|
765
|
+
path: `/api/v1/roblox/experiences/${encodeURIComponent(universeId)}/history`,
|
|
766
|
+
query: pickQuery(options, {
|
|
767
|
+
granularity: "granularity",
|
|
768
|
+
from: "from",
|
|
769
|
+
to: "to",
|
|
770
|
+
"as-of": "as_of"
|
|
771
|
+
})
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (matches(command, ["roblox", "coverage"])) {
|
|
776
|
+
assertSupportedOptions(options, { command: "gmc roblox coverage", allowed: ["as-of"] });
|
|
777
|
+
assertNoExtraPositionals(positional, { expected: 0, usage: "gmc roblox coverage --json" });
|
|
778
|
+
return {
|
|
779
|
+
path: "/api/v1/roblox/coverage",
|
|
780
|
+
query: pickQuery(options, { "as-of": "as_of" })
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
653
784
|
return null;
|
|
654
785
|
}
|
|
655
786
|
|
|
@@ -1243,6 +1374,34 @@ function assertAppid(value, usage) {
|
|
|
1243
1374
|
return appid;
|
|
1244
1375
|
}
|
|
1245
1376
|
|
|
1377
|
+
/**
|
|
1378
|
+
* NAK-715. Validates the universeId positional for the Roblox per-experience
|
|
1379
|
+
* commands, mirroring the path-parameter check the app applies.
|
|
1380
|
+
*
|
|
1381
|
+
* It returns a STRING and never a number, which is the point: a Roblox
|
|
1382
|
+
* universe id is a 64-bit value, and `Number.parseInt` would round anything
|
|
1383
|
+
* above 2^53 into a different experience's id. The lexical test is
|
|
1384
|
+
* `/^[1-9][0-9]*$/` for the same reason `assertAppid` above uses a lexical
|
|
1385
|
+
* test — `Number` also accepts the exponent, hex and decimal-point spellings
|
|
1386
|
+
* the app's validator rejects, and leading zeros would make one experience
|
|
1387
|
+
* reachable under two spellings. This check is advisory; the server remains
|
|
1388
|
+
* the enforcing authority.
|
|
1389
|
+
*
|
|
1390
|
+
* A Roblox universe id is NOT a Steam appid: the two id spaces are unrelated
|
|
1391
|
+
* and this value never reaches a Steam route.
|
|
1392
|
+
*/
|
|
1393
|
+
function assertUniverseId(value, usage) {
|
|
1394
|
+
const raw = value == null ? "" : String(value).trim();
|
|
1395
|
+
if (!/^[1-9][0-9]{0,18}$/.test(raw)) {
|
|
1396
|
+
writeJsonError({
|
|
1397
|
+
code: "CLI_USAGE_ERROR",
|
|
1398
|
+
message: `Missing or invalid universeId (must be a positive decimal Roblox universe id, not a Steam appid). Usage: ${usage}`
|
|
1399
|
+
});
|
|
1400
|
+
process.exit(EXIT_USAGE);
|
|
1401
|
+
}
|
|
1402
|
+
return raw;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1246
1405
|
function assertResolveQuery(value) {
|
|
1247
1406
|
if (value == null || String(value).trim() === "") {
|
|
1248
1407
|
writeJsonError({
|
package/src/requests.test.js
CHANGED
|
@@ -1297,3 +1297,174 @@ test('trend-stories preserves story, edition, locale and list pagination options
|
|
|
1297
1297
|
assert.equal(captureUsageFailure(() => buildRequestFromArgv(['steam', 'trend-stories', '--family', 'mood'])).exitCode, 2);
|
|
1298
1298
|
assert.equal(captureUsageFailure(() => buildRequestFromArgv(['steam', 'trend-stories', 'unexpected'])).exitCode, 2);
|
|
1299
1299
|
});
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* NAK-715: `gmc roblox ...`.
|
|
1303
|
+
*
|
|
1304
|
+
* The CLI is a pass-through here on the same rule the tag-trends block above
|
|
1305
|
+
* documents — the per-endpoint allowlists, enum members, limit ceilings and
|
|
1306
|
+
* window bounds live on the server — so these tests pin the MAPPING (flag name
|
|
1307
|
+
* to query parameter, omission when unset, path construction) and the one
|
|
1308
|
+
* thing the CLI does own: a universe id is a STRING and is not a Steam appid.
|
|
1309
|
+
*/
|
|
1310
|
+
test("(NAK-715) roblox charts defaults to the bare endpoint and lets the server pick the cohort", () => {
|
|
1311
|
+
const request = buildRequestFromArgv(["roblox", "charts", "--json"]);
|
|
1312
|
+
|
|
1313
|
+
assert.equal(request.path, "/api/v1/roblox/charts");
|
|
1314
|
+
// Empty, not {country: "all"}: a default copied here could drift from the
|
|
1315
|
+
// server's.
|
|
1316
|
+
assert.deepEqual(request.query, {});
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
test("(NAK-715) roblox charts maps every flag to its query parameter", () => {
|
|
1320
|
+
const request = buildRequestFromArgv([
|
|
1321
|
+
"roblox", "charts",
|
|
1322
|
+
"--country", "JP",
|
|
1323
|
+
"--device", "computer",
|
|
1324
|
+
"--sort", "top-revisited",
|
|
1325
|
+
"--as-of", "2026-09-14T00:00:00.000Z",
|
|
1326
|
+
"--limit", "50",
|
|
1327
|
+
"--offset", "10",
|
|
1328
|
+
"--json"
|
|
1329
|
+
]);
|
|
1330
|
+
|
|
1331
|
+
assert.deepEqual(request.query, {
|
|
1332
|
+
country: "JP",
|
|
1333
|
+
device: "computer",
|
|
1334
|
+
// Still forwarded although the source stopped publishing this sort: a
|
|
1335
|
+
// historical cutoff has real data, and the server reports the gap.
|
|
1336
|
+
sort: "top-revisited",
|
|
1337
|
+
// The kebab-case flag reaches the endpoint's snake_case parameter.
|
|
1338
|
+
as_of: "2026-09-14T00:00:00.000Z",
|
|
1339
|
+
limit: "50",
|
|
1340
|
+
offset: "10"
|
|
1341
|
+
});
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
test("(NAK-715) roblox experiences maps the browse filters and the cursor", () => {
|
|
1345
|
+
const request = buildRequestFromArgv([
|
|
1346
|
+
"roblox", "experiences",
|
|
1347
|
+
"--country", "US",
|
|
1348
|
+
"--eligibility-days", "30",
|
|
1349
|
+
"--min-player-count", "1000",
|
|
1350
|
+
"--max-rank", "50",
|
|
1351
|
+
"--genre", "Adventure",
|
|
1352
|
+
"--creator-type", "Group",
|
|
1353
|
+
"--detail", "chart_only",
|
|
1354
|
+
"--order-by", "player_count",
|
|
1355
|
+
"--order", "desc",
|
|
1356
|
+
"--limit", "25",
|
|
1357
|
+
"--cursor", "abc.def",
|
|
1358
|
+
"--json"
|
|
1359
|
+
]);
|
|
1360
|
+
|
|
1361
|
+
assert.equal(request.path, "/api/v1/roblox/experiences");
|
|
1362
|
+
assert.deepEqual(request.query, {
|
|
1363
|
+
country: "US",
|
|
1364
|
+
eligibility_days: "30",
|
|
1365
|
+
min_player_count: "1000",
|
|
1366
|
+
max_rank: "50",
|
|
1367
|
+
genre: "Adventure",
|
|
1368
|
+
creator_type: "Group",
|
|
1369
|
+
detail: "chart_only",
|
|
1370
|
+
order_by: "player_count",
|
|
1371
|
+
order: "desc",
|
|
1372
|
+
limit: "25",
|
|
1373
|
+
cursor: "abc.def"
|
|
1374
|
+
});
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* The id is carried as the decimal STRING the user typed. Parsing it into a
|
|
1379
|
+
* number would round anything above 2^53 into a different experience's id.
|
|
1380
|
+
*/
|
|
1381
|
+
test("(NAK-715) a universe id travels as a decimal string, never as a number", () => {
|
|
1382
|
+
const request = buildRequestFromArgv(["roblox", "experience", "9007199254740993", "--json"]);
|
|
1383
|
+
|
|
1384
|
+
assert.equal(request.path, "/api/v1/roblox/experiences/9007199254740993");
|
|
1385
|
+
assert.deepEqual(request.query, {});
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
test("(NAK-715) roblox history builds the history path and maps the window flags", () => {
|
|
1389
|
+
const request = buildRequestFromArgv([
|
|
1390
|
+
"roblox", "history", "4924922222",
|
|
1391
|
+
"--granularity", "hourly",
|
|
1392
|
+
"--from", "2026-09-13T00:00:00.000Z",
|
|
1393
|
+
"--to", "2026-09-20T00:00:00.000Z",
|
|
1394
|
+
"--json"
|
|
1395
|
+
]);
|
|
1396
|
+
|
|
1397
|
+
assert.equal(request.path, "/api/v1/roblox/experiences/4924922222/history");
|
|
1398
|
+
assert.deepEqual(request.query, {
|
|
1399
|
+
granularity: "hourly",
|
|
1400
|
+
from: "2026-09-13T00:00:00.000Z",
|
|
1401
|
+
to: "2026-09-20T00:00:00.000Z"
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
test("(NAK-715) roblox coverage takes only the cutoff", () => {
|
|
1406
|
+
assert.deepEqual(buildRequestFromArgv(["roblox", "coverage", "--json"]), {
|
|
1407
|
+
path: "/api/v1/roblox/coverage",
|
|
1408
|
+
query: {}
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
const { exitCode } = captureUsageFailure(() =>
|
|
1412
|
+
buildRequestFromArgv(["roblox", "coverage", "--country", "JP", "--json"])
|
|
1413
|
+
);
|
|
1414
|
+
assert.equal(exitCode, 2);
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* A Steam appid spelling the app's validator rejects must not reach the wire
|
|
1419
|
+
* looking fine, and it must never be mistaken for a universe id.
|
|
1420
|
+
*/
|
|
1421
|
+
test("(NAK-715) a malformed universe id is a usage error naming the Steam-appid confusion", () => {
|
|
1422
|
+
for (const bad of ["730.0", "0x2da", "7e2", "-1", "0", "007", "abc"]) {
|
|
1423
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
1424
|
+
buildRequestFromArgv(["roblox", "experience", bad, "--json"])
|
|
1425
|
+
);
|
|
1426
|
+
assert.equal(exitCode, 2, `expected ${bad} to be refused`);
|
|
1427
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
1428
|
+
assert.match(output.error.message, /not a Steam appid/);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const missing = captureUsageFailure(() => buildRequestFromArgv(["roblox", "history", "--json"]));
|
|
1432
|
+
assert.equal(missing.exitCode, 2);
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
test("(NAK-715) a flag the endpoint has no parameter for is a usage error, not a dropped narrowing", () => {
|
|
1436
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
1437
|
+
buildRequestFromArgv(["roblox", "charts", "--source", "steam", "--json"])
|
|
1438
|
+
);
|
|
1439
|
+
|
|
1440
|
+
assert.equal(exitCode, 2);
|
|
1441
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
1442
|
+
assert.match(output.error.message, /--source is not supported by gmc roblox charts/);
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
test("(NAK-715) a stray positional is a usage error rather than a silently ignored argument", () => {
|
|
1446
|
+
const { exitCode } = captureUsageFailure(() =>
|
|
1447
|
+
buildRequestFromArgv(["roblox", "charts", "cozy", "--json"])
|
|
1448
|
+
);
|
|
1449
|
+
assert.equal(exitCode, 2);
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Mirrors the tag-trends precedent: removing these from
|
|
1454
|
+
* PROVISIONAL_BETA_COMMANDS would leave every test above green while the
|
|
1455
|
+
* commands silently vanished from `--help`.
|
|
1456
|
+
*/
|
|
1457
|
+
test("(NAK-715) the roblox commands are published as provisional in --help", () => {
|
|
1458
|
+
for (const command of [
|
|
1459
|
+
"gmc roblox charts --country all --sort top-playing-now --json",
|
|
1460
|
+
"gmc roblox experiences --country all --limit 50 --json",
|
|
1461
|
+
"gmc roblox experience <universeId> --json",
|
|
1462
|
+
"gmc roblox history <universeId> --granularity daily|hourly --json",
|
|
1463
|
+
"gmc roblox coverage --json"
|
|
1464
|
+
]) {
|
|
1465
|
+
assert.ok(
|
|
1466
|
+
PROVISIONAL_BETA_COMMANDS.includes(command),
|
|
1467
|
+
`${command} must stay listed as provisional`
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
});
|