@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@witchpot/gmc",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "Command line tools for Game Market Copilot market research.",
6
6
  "license": "UNLICENSED",
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.9.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({
@@ -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
+ });