@idosgames/mcp 0.1.39 → 0.1.40

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.
Files changed (37) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +1 -1
  4. package/registry/index.json +36 -36
  5. package/registry/modules/ai.json +1 -1
  6. package/registry/modules/board-game.json +1 -1
  7. package/registry/modules/boosts.json +1 -1
  8. package/registry/modules/character.json +1 -1
  9. package/registry/modules/chat.json +1 -1
  10. package/registry/modules/collections.json +1 -1
  11. package/registry/modules/coop-events.json +1 -1
  12. package/registry/modules/craft.json +1 -1
  13. package/registry/modules/deals.json +1 -1
  14. package/registry/modules/events.json +1 -1
  15. package/registry/modules/friends.json +1 -1
  16. package/registry/modules/hero-run.json +1 -1
  17. package/registry/modules/idle-rpg.json +1 -1
  18. package/registry/modules/inventory.json +1 -1
  19. package/registry/modules/invite.json +1 -1
  20. package/registry/modules/leaderboard.json +1 -1
  21. package/registry/modules/lootboxes.json +1 -1
  22. package/registry/modules/mailbox.json +1 -1
  23. package/registry/modules/marketplace.json +1 -1
  24. package/registry/modules/mini-game.json +1 -1
  25. package/registry/modules/multiplayer.json +1 -1
  26. package/registry/modules/premium.json +1 -1
  27. package/registry/modules/profile.json +1 -1
  28. package/registry/modules/pvp.json +1 -1
  29. package/registry/modules/quests.json +1 -1
  30. package/registry/modules/rewards.json +1 -1
  31. package/registry/modules/season.json +1 -1
  32. package/registry/modules/store.json +9 -5
  33. package/registry/modules/tutorials.json +1 -1
  34. package/registry/modules/video-studio.json +1 -1
  35. package/registry/modules/voxelcraft.json +1 -1
  36. package/registry/modules/workshop.json +1 -1
  37. package/registry/skills/store-system.json +1 -1
package/dist/cli.js CHANGED
@@ -2248,7 +2248,7 @@ async function callVideoTool(name, args) {
2248
2248
  }
2249
2249
 
2250
2250
  // src/server.ts
2251
- var SERVER_VERSION = true ? "0.1.39" : "0.0.0-dev";
2251
+ var SERVER_VERSION = true ? "0.1.40" : "0.0.0-dev";
2252
2252
  var readOnly2 = (title) => ({
2253
2253
  title,
2254
2254
  readOnlyHint: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idosgames/mcp",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "mcpName": "com.idosgames/sdk",
5
5
  "description": "MCP server that serves the iDosGames Module & Skills Registry to AI coding agents (Claude Code, Codex, Cursor…): list/pull composable game modules and the host scaffold, and load skills for @idosgames/core, the module contract, and composition.",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  {
25
25
  "path": "package.json",
26
- "content": "{\n \"name\": \"@idosgames/base-template\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The base every AI Coder project starts from: sign-in and the lobby (both from @idosgames/app-shell) on the host shell. The systems (shop, heroes, leaderboards…) and the games are modules plugged in via src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.16.0\",\n \"@idosgames/core\": \"0.23.0\",\n \"@idosgames/module-sdk\": \"0.13.0\",\n \"@idosgames/react\": \"0.6.0\",\n \"@idosgames/wallet\": \"0.6.6\",\n \"@reown/appkit\": \"1.8.23\",\n \"@reown/appkit-adapter-wagmi\": \"1.8.23\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
26
+ "content": "{\n \"name\": \"@idosgames/base-template\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The base every AI Coder project starts from: sign-in and the lobby (both from @idosgames/app-shell) on the host shell. The systems (shop, heroes, leaderboards…) and the games are modules plugged in via src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.16.0\",\n \"@idosgames/core\": \"0.24.0\",\n \"@idosgames/module-sdk\": \"0.13.0\",\n \"@idosgames/react\": \"0.6.0\",\n \"@idosgames/wallet\": \"0.6.6\",\n \"@reown/appkit\": \"1.8.23\",\n \"@reown/appkit-adapter-wagmi\": \"1.8.23\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
27
27
  },
28
28
  {
29
29
  "path": "public/sw.js",
@@ -1,7 +1,7 @@
1
1
  {
2
- "generatedFromCommit": "31c69686452d029aaccb498eb85d3a6fff39efce",
2
+ "generatedFromCommit": "7b8dc6c1cb322923cb1c2ab823b0e6beabaa2626",
3
3
  "runtimePackages": {
4
- "@idosgames/core": "0.23.0",
4
+ "@idosgames/core": "0.24.0",
5
5
  "@idosgames/wallet": "0.6.6",
6
6
  "@idosgames/module-sdk": "0.13.0",
7
7
  "@idosgames/react": "0.6.0",
@@ -47,13 +47,13 @@
47
47
  },
48
48
  "version": "0.1.3",
49
49
  "dependencies": {
50
- "@idosgames/core": "0.23.0",
50
+ "@idosgames/core": "0.24.0",
51
51
  "@idosgames/module-sdk": "0.13.0",
52
52
  "@idosgames/react": "0.6.0",
53
53
  "react": "19.2.7"
54
54
  },
55
- "fileCount": 6,
56
- "contentHash": "9d642c2b6780919c897c46931b81f42441e38240abf6c723522da6369c4a6ba6"
55
+ "fileCount": 7,
56
+ "contentHash": "9ae53fef8405e8c52518d98d011d1cc217bd804ccc416e3eaa8a5587cfecfdc5"
57
57
  },
58
58
  {
59
59
  "id": "premium",
@@ -84,7 +84,7 @@
84
84
  },
85
85
  "version": "0.1.3",
86
86
  "dependencies": {
87
- "@idosgames/core": "0.23.0",
87
+ "@idosgames/core": "0.24.0",
88
88
  "@idosgames/module-sdk": "0.13.0",
89
89
  "@idosgames/react": "0.6.0",
90
90
  "react": "19.2.7"
@@ -143,7 +143,7 @@
143
143
  ]
144
144
  },
145
145
  "dependencies": {
146
- "@idosgames/core": "0.23.0",
146
+ "@idosgames/core": "0.24.0",
147
147
  "@idosgames/module-sdk": "0.13.0",
148
148
  "@idosgames/react": "0.6.0",
149
149
  "react": "19.2.7"
@@ -180,7 +180,7 @@
180
180
  },
181
181
  "version": "0.1.1",
182
182
  "dependencies": {
183
- "@idosgames/core": "0.23.0",
183
+ "@idosgames/core": "0.24.0",
184
184
  "@idosgames/module-sdk": "0.13.0",
185
185
  "@idosgames/react": "0.6.0",
186
186
  "react": "19.2.7"
@@ -217,7 +217,7 @@
217
217
  },
218
218
  "version": "0.1.3",
219
219
  "dependencies": {
220
- "@idosgames/core": "0.23.0",
220
+ "@idosgames/core": "0.24.0",
221
221
  "@idosgames/module-sdk": "0.13.0",
222
222
  "@idosgames/react": "0.6.0",
223
223
  "react": "19.2.7"
@@ -253,7 +253,7 @@
253
253
  },
254
254
  "version": "0.1.1",
255
255
  "dependencies": {
256
- "@idosgames/core": "0.23.0",
256
+ "@idosgames/core": "0.24.0",
257
257
  "@idosgames/module-sdk": "0.13.0",
258
258
  "@idosgames/react": "0.6.0",
259
259
  "react": "19.2.7"
@@ -294,7 +294,7 @@
294
294
  },
295
295
  "version": "0.1.4",
296
296
  "dependencies": {
297
- "@idosgames/core": "0.23.0",
297
+ "@idosgames/core": "0.24.0",
298
298
  "@idosgames/module-sdk": "0.13.0",
299
299
  "@idosgames/react": "0.6.0",
300
300
  "react": "19.2.7"
@@ -330,7 +330,7 @@
330
330
  },
331
331
  "version": "0.1.3",
332
332
  "dependencies": {
333
- "@idosgames/core": "0.23.0",
333
+ "@idosgames/core": "0.24.0",
334
334
  "@idosgames/module-sdk": "0.13.0",
335
335
  "@idosgames/react": "0.6.0",
336
336
  "react": "19.2.7"
@@ -369,7 +369,7 @@
369
369
  },
370
370
  "version": "0.1.3",
371
371
  "dependencies": {
372
- "@idosgames/core": "0.23.0",
372
+ "@idosgames/core": "0.24.0",
373
373
  "@idosgames/module-sdk": "0.13.0",
374
374
  "@idosgames/react": "0.6.0",
375
375
  "react": "19.2.7"
@@ -408,7 +408,7 @@
408
408
  },
409
409
  "version": "0.1.3",
410
410
  "dependencies": {
411
- "@idosgames/core": "0.23.0",
411
+ "@idosgames/core": "0.24.0",
412
412
  "@idosgames/module-sdk": "0.13.0",
413
413
  "@idosgames/react": "0.6.0",
414
414
  "react": "19.2.7"
@@ -448,7 +448,7 @@
448
448
  },
449
449
  "version": "0.1.2",
450
450
  "dependencies": {
451
- "@idosgames/core": "0.23.0",
451
+ "@idosgames/core": "0.24.0",
452
452
  "@idosgames/module-sdk": "0.13.0",
453
453
  "@idosgames/react": "0.6.0",
454
454
  "react": "19.2.7"
@@ -489,7 +489,7 @@
489
489
  },
490
490
  "version": "0.1.3",
491
491
  "dependencies": {
492
- "@idosgames/core": "0.23.0",
492
+ "@idosgames/core": "0.24.0",
493
493
  "@idosgames/module-sdk": "0.13.0",
494
494
  "@idosgames/react": "0.6.0",
495
495
  "react": "19.2.7"
@@ -528,7 +528,7 @@
528
528
  },
529
529
  "version": "0.1.3",
530
530
  "dependencies": {
531
- "@idosgames/core": "0.23.0",
531
+ "@idosgames/core": "0.24.0",
532
532
  "@idosgames/module-sdk": "0.13.0",
533
533
  "@idosgames/react": "0.6.0",
534
534
  "react": "19.2.7"
@@ -570,7 +570,7 @@
570
570
  },
571
571
  "version": "0.1.4",
572
572
  "dependencies": {
573
- "@idosgames/core": "0.23.0",
573
+ "@idosgames/core": "0.24.0",
574
574
  "@idosgames/module-sdk": "0.13.0",
575
575
  "@idosgames/react": "0.6.0",
576
576
  "react": "19.2.7"
@@ -607,7 +607,7 @@
607
607
  },
608
608
  "version": "0.1.3",
609
609
  "dependencies": {
610
- "@idosgames/core": "0.23.0",
610
+ "@idosgames/core": "0.24.0",
611
611
  "@idosgames/module-sdk": "0.13.0",
612
612
  "@idosgames/react": "0.6.0",
613
613
  "react": "19.2.7"
@@ -648,7 +648,7 @@
648
648
  },
649
649
  "version": "0.1.3",
650
650
  "dependencies": {
651
- "@idosgames/core": "0.23.0",
651
+ "@idosgames/core": "0.24.0",
652
652
  "@idosgames/module-sdk": "0.13.0",
653
653
  "@idosgames/react": "0.6.0",
654
654
  "react": "19.2.7"
@@ -688,7 +688,7 @@
688
688
  },
689
689
  "version": "0.1.3",
690
690
  "dependencies": {
691
- "@idosgames/core": "0.23.0",
691
+ "@idosgames/core": "0.24.0",
692
692
  "@idosgames/module-sdk": "0.13.0",
693
693
  "@idosgames/react": "0.6.0",
694
694
  "react": "19.2.7"
@@ -728,7 +728,7 @@
728
728
  },
729
729
  "version": "0.1.2",
730
730
  "dependencies": {
731
- "@idosgames/core": "0.23.0",
731
+ "@idosgames/core": "0.24.0",
732
732
  "@idosgames/module-sdk": "0.13.0",
733
733
  "@idosgames/react": "0.6.0",
734
734
  "react": "19.2.7"
@@ -768,7 +768,7 @@
768
768
  },
769
769
  "version": "0.1.3",
770
770
  "dependencies": {
771
- "@idosgames/core": "0.23.0",
771
+ "@idosgames/core": "0.24.0",
772
772
  "@idosgames/module-sdk": "0.13.0",
773
773
  "@idosgames/react": "0.6.0",
774
774
  "react": "19.2.7"
@@ -809,7 +809,7 @@
809
809
  },
810
810
  "version": "0.1.2",
811
811
  "dependencies": {
812
- "@idosgames/core": "0.23.0",
812
+ "@idosgames/core": "0.24.0",
813
813
  "@idosgames/module-sdk": "0.13.0",
814
814
  "@idosgames/react": "0.6.0",
815
815
  "react": "19.2.7"
@@ -847,7 +847,7 @@
847
847
  },
848
848
  "version": "0.1.2",
849
849
  "dependencies": {
850
- "@idosgames/core": "0.23.0",
850
+ "@idosgames/core": "0.24.0",
851
851
  "@idosgames/module-sdk": "0.13.0",
852
852
  "@idosgames/react": "0.6.0",
853
853
  "react": "19.2.7"
@@ -891,7 +891,7 @@
891
891
  },
892
892
  "version": "0.2.1",
893
893
  "dependencies": {
894
- "@idosgames/core": "0.23.0",
894
+ "@idosgames/core": "0.24.0",
895
895
  "@idosgames/module-sdk": "0.13.0",
896
896
  "@idosgames/react": "0.6.0",
897
897
  "react": "19.2.7"
@@ -930,7 +930,7 @@
930
930
  },
931
931
  "version": "0.1.5",
932
932
  "dependencies": {
933
- "@idosgames/core": "0.23.0",
933
+ "@idosgames/core": "0.24.0",
934
934
  "@idosgames/module-sdk": "0.13.0",
935
935
  "@idosgames/react": "0.6.0",
936
936
  "react": "19.2.7"
@@ -967,7 +967,7 @@
967
967
  },
968
968
  "version": "0.1.3",
969
969
  "dependencies": {
970
- "@idosgames/core": "0.23.0",
970
+ "@idosgames/core": "0.24.0",
971
971
  "@idosgames/module-sdk": "0.13.0",
972
972
  "@idosgames/react": "0.6.0",
973
973
  "react": "19.2.7"
@@ -1011,7 +1011,7 @@
1011
1011
  },
1012
1012
  "version": "0.1.3",
1013
1013
  "dependencies": {
1014
- "@idosgames/core": "0.23.0",
1014
+ "@idosgames/core": "0.24.0",
1015
1015
  "@idosgames/module-sdk": "0.13.0",
1016
1016
  "@idosgames/react": "0.6.0",
1017
1017
  "react": "19.2.7"
@@ -1046,7 +1046,7 @@
1046
1046
  },
1047
1047
  "version": "0.1.3",
1048
1048
  "dependencies": {
1049
- "@idosgames/core": "0.23.0",
1049
+ "@idosgames/core": "0.24.0",
1050
1050
  "@idosgames/module-sdk": "0.13.0",
1051
1051
  "@idosgames/react": "0.6.0",
1052
1052
  "react": "19.2.7"
@@ -1096,7 +1096,7 @@
1096
1096
  ]
1097
1097
  },
1098
1098
  "dependencies": {
1099
- "@idosgames/core": "0.23.0",
1099
+ "@idosgames/core": "0.24.0",
1100
1100
  "@idosgames/module-sdk": "0.13.0",
1101
1101
  "@idosgames/react": "0.6.0",
1102
1102
  "@idosgames/wallet": "0.6.6",
@@ -1182,7 +1182,7 @@
1182
1182
  },
1183
1183
  "dependencies": {
1184
1184
  "@idosgames/characters": "0.1.1",
1185
- "@idosgames/core": "0.23.0",
1185
+ "@idosgames/core": "0.24.0",
1186
1186
  "@idosgames/module-sdk": "0.13.0",
1187
1187
  "@idosgames/react": "0.6.0",
1188
1188
  "phaser": "4.2.1",
@@ -1257,7 +1257,7 @@
1257
1257
  ]
1258
1258
  },
1259
1259
  "dependencies": {
1260
- "@idosgames/core": "0.23.0",
1260
+ "@idosgames/core": "0.24.0",
1261
1261
  "@idosgames/module-sdk": "0.13.0",
1262
1262
  "@idosgames/react": "0.6.0",
1263
1263
  "phaser": "4.2.1",
@@ -1348,7 +1348,7 @@
1348
1348
  "version": "0.7.3",
1349
1349
  "dependencies": {
1350
1350
  "@idosgames/characters": "0.1.1",
1351
- "@idosgames/core": "0.23.0",
1351
+ "@idosgames/core": "0.24.0",
1352
1352
  "@idosgames/module-sdk": "0.13.0",
1353
1353
  "@idosgames/react": "0.6.0",
1354
1354
  "react": "19.2.7",
@@ -1384,7 +1384,7 @@
1384
1384
  },
1385
1385
  "version": "0.1.3",
1386
1386
  "dependencies": {
1387
- "@idosgames/core": "0.23.0",
1387
+ "@idosgames/core": "0.24.0",
1388
1388
  "@idosgames/module-sdk": "0.13.0",
1389
1389
  "@idosgames/react": "0.6.0",
1390
1390
  "react": "19.2.7"
@@ -1444,7 +1444,7 @@
1444
1444
  "version": "0.1.1",
1445
1445
  "dependencies": {
1446
1446
  "@idosgames/characters": "0.1.1",
1447
- "@idosgames/core": "0.23.0",
1447
+ "@idosgames/core": "0.24.0",
1448
1448
  "@idosgames/module-sdk": "0.13.0",
1449
1449
  "@idosgames/react": "0.6.0",
1450
1450
  "@idosgames/timeline": "0.1.0",
@@ -30,7 +30,7 @@
30
30
  "version": "0.1.3"
31
31
  },
32
32
  "dependencies": {
33
- "@idosgames/core": "0.23.0",
33
+ "@idosgames/core": "0.24.0",
34
34
  "@idosgames/module-sdk": "0.13.0",
35
35
  "@idosgames/react": "0.6.0",
36
36
  "react": "19.2.7"
@@ -45,7 +45,7 @@
45
45
  }
46
46
  },
47
47
  "dependencies": {
48
- "@idosgames/core": "0.23.0",
48
+ "@idosgames/core": "0.24.0",
49
49
  "@idosgames/module-sdk": "0.13.0",
50
50
  "@idosgames/react": "0.6.0",
51
51
  "@idosgames/wallet": "0.6.6",
@@ -31,7 +31,7 @@
31
31
  "version": "0.1.1"
32
32
  },
33
33
  "dependencies": {
34
- "@idosgames/core": "0.23.0",
34
+ "@idosgames/core": "0.24.0",
35
35
  "@idosgames/module-sdk": "0.13.0",
36
36
  "@idosgames/react": "0.6.0",
37
37
  "react": "19.2.7"
@@ -54,7 +54,7 @@
54
54
  }
55
55
  },
56
56
  "dependencies": {
57
- "@idosgames/core": "0.23.0",
57
+ "@idosgames/core": "0.24.0",
58
58
  "@idosgames/module-sdk": "0.13.0",
59
59
  "@idosgames/react": "0.6.0",
60
60
  "react": "19.2.7"
@@ -35,7 +35,7 @@
35
35
  "version": "0.1.2"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.23.0",
38
+ "@idosgames/core": "0.24.0",
39
39
  "@idosgames/module-sdk": "0.13.0",
40
40
  "@idosgames/react": "0.6.0",
41
41
  "react": "19.2.7"
@@ -34,7 +34,7 @@
34
34
  "version": "0.1.3"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.23.0",
37
+ "@idosgames/core": "0.24.0",
38
38
  "@idosgames/module-sdk": "0.13.0",
39
39
  "@idosgames/react": "0.6.0",
40
40
  "react": "19.2.7"
@@ -32,7 +32,7 @@
32
32
  "version": "0.1.3"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.23.0",
35
+ "@idosgames/core": "0.24.0",
36
36
  "@idosgames/module-sdk": "0.13.0",
37
37
  "@idosgames/react": "0.6.0",
38
38
  "react": "19.2.7"
@@ -35,7 +35,7 @@
35
35
  "version": "0.1.2"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.23.0",
38
+ "@idosgames/core": "0.24.0",
39
39
  "@idosgames/module-sdk": "0.13.0",
40
40
  "@idosgames/react": "0.6.0",
41
41
  "react": "19.2.7"
@@ -34,7 +34,7 @@
34
34
  "version": "0.1.3"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.23.0",
37
+ "@idosgames/core": "0.24.0",
38
38
  "@idosgames/module-sdk": "0.13.0",
39
39
  "@idosgames/react": "0.6.0",
40
40
  "react": "19.2.7"
@@ -37,7 +37,7 @@
37
37
  "version": "0.1.4"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/core": "0.23.0",
40
+ "@idosgames/core": "0.24.0",
41
41
  "@idosgames/module-sdk": "0.13.0",
42
42
  "@idosgames/react": "0.6.0",
43
43
  "react": "19.2.7"
@@ -35,7 +35,7 @@
35
35
  "version": "0.1.3"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.23.0",
38
+ "@idosgames/core": "0.24.0",
39
39
  "@idosgames/module-sdk": "0.13.0",
40
40
  "@idosgames/react": "0.6.0",
41
41
  "react": "19.2.7"
@@ -69,7 +69,7 @@
69
69
  }
70
70
  },
71
71
  "dependencies": {
72
- "@idosgames/core": "0.23.0",
72
+ "@idosgames/core": "0.24.0",
73
73
  "@idosgames/module-sdk": "0.13.0",
74
74
  "@idosgames/react": "0.6.0",
75
75
  "phaser": "4.2.1",
@@ -76,7 +76,7 @@
76
76
  },
77
77
  "dependencies": {
78
78
  "@idosgames/characters": "0.1.1",
79
- "@idosgames/core": "0.23.0",
79
+ "@idosgames/core": "0.24.0",
80
80
  "@idosgames/module-sdk": "0.13.0",
81
81
  "@idosgames/react": "0.6.0",
82
82
  "phaser": "4.2.1",
@@ -32,7 +32,7 @@
32
32
  "version": "0.1.1"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.23.0",
35
+ "@idosgames/core": "0.24.0",
36
36
  "@idosgames/module-sdk": "0.13.0",
37
37
  "@idosgames/react": "0.6.0",
38
38
  "react": "19.2.7"
@@ -35,7 +35,7 @@
35
35
  "version": "0.1.3"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.23.0",
38
+ "@idosgames/core": "0.24.0",
39
39
  "@idosgames/module-sdk": "0.13.0",
40
40
  "@idosgames/react": "0.6.0",
41
41
  "react": "19.2.7"
@@ -36,7 +36,7 @@
36
36
  "version": "0.1.4"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.23.0",
39
+ "@idosgames/core": "0.24.0",
40
40
  "@idosgames/module-sdk": "0.13.0",
41
41
  "@idosgames/react": "0.6.0",
42
42
  "react": "19.2.7"
@@ -32,7 +32,7 @@
32
32
  "version": "0.1.3"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.23.0",
35
+ "@idosgames/core": "0.24.0",
36
36
  "@idosgames/module-sdk": "0.13.0",
37
37
  "@idosgames/react": "0.6.0",
38
38
  "react": "19.2.7"
@@ -36,7 +36,7 @@
36
36
  "version": "0.1.2"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.23.0",
39
+ "@idosgames/core": "0.24.0",
40
40
  "@idosgames/module-sdk": "0.13.0",
41
41
  "@idosgames/react": "0.6.0",
42
42
  "react": "19.2.7"
@@ -36,7 +36,7 @@
36
36
  "version": "0.1.3"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.23.0",
39
+ "@idosgames/core": "0.24.0",
40
40
  "@idosgames/module-sdk": "0.13.0",
41
41
  "@idosgames/react": "0.6.0",
42
42
  "react": "19.2.7"
@@ -30,7 +30,7 @@
30
30
  "version": "0.1.3"
31
31
  },
32
32
  "dependencies": {
33
- "@idosgames/core": "0.23.0",
33
+ "@idosgames/core": "0.24.0",
34
34
  "@idosgames/module-sdk": "0.13.0",
35
35
  "@idosgames/react": "0.6.0",
36
36
  "react": "19.2.7"
@@ -39,7 +39,7 @@
39
39
  "version": "0.2.1"
40
40
  },
41
41
  "dependencies": {
42
- "@idosgames/core": "0.23.0",
42
+ "@idosgames/core": "0.24.0",
43
43
  "@idosgames/module-sdk": "0.13.0",
44
44
  "@idosgames/react": "0.6.0",
45
45
  "react": "19.2.7"
@@ -32,7 +32,7 @@
32
32
  "version": "0.1.3"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.23.0",
35
+ "@idosgames/core": "0.24.0",
36
36
  "@idosgames/module-sdk": "0.13.0",
37
37
  "@idosgames/react": "0.6.0",
38
38
  "react": "19.2.7"
@@ -34,7 +34,7 @@
34
34
  "version": "0.1.5"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.23.0",
37
+ "@idosgames/core": "0.24.0",
38
38
  "@idosgames/module-sdk": "0.13.0",
39
39
  "@idosgames/react": "0.6.0",
40
40
  "react": "19.2.7"
@@ -33,7 +33,7 @@
33
33
  "version": "0.1.2"
34
34
  },
35
35
  "dependencies": {
36
- "@idosgames/core": "0.23.0",
36
+ "@idosgames/core": "0.24.0",
37
37
  "@idosgames/module-sdk": "0.13.0",
38
38
  "@idosgames/react": "0.6.0",
39
39
  "react": "19.2.7"
@@ -34,7 +34,7 @@
34
34
  "version": "0.1.3"
35
35
  },
36
36
  "dependencies": {
37
- "@idosgames/core": "0.23.0",
37
+ "@idosgames/core": "0.24.0",
38
38
  "@idosgames/module-sdk": "0.13.0",
39
39
  "@idosgames/react": "0.6.0",
40
40
  "react": "19.2.7"
@@ -31,7 +31,7 @@
31
31
  "version": "0.1.3"
32
32
  },
33
33
  "dependencies": {
34
- "@idosgames/core": "0.23.0",
34
+ "@idosgames/core": "0.24.0",
35
35
  "@idosgames/module-sdk": "0.13.0",
36
36
  "@idosgames/react": "0.6.0",
37
37
  "react": "19.2.7"
@@ -36,7 +36,7 @@
36
36
  "version": "0.1.3"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.23.0",
39
+ "@idosgames/core": "0.24.0",
40
40
  "@idosgames/module-sdk": "0.13.0",
41
41
  "@idosgames/react": "0.6.0",
42
42
  "react": "19.2.7"
@@ -36,16 +36,16 @@
36
36
  "version": "0.1.3"
37
37
  },
38
38
  "dependencies": {
39
- "@idosgames/core": "0.23.0",
39
+ "@idosgames/core": "0.24.0",
40
40
  "@idosgames/module-sdk": "0.13.0",
41
41
  "@idosgames/react": "0.6.0",
42
42
  "react": "19.2.7"
43
43
  },
44
- "contentHash": "9d642c2b6780919c897c46931b81f42441e38240abf6c723522da6369c4a6ba6",
44
+ "contentHash": "9ae53fef8405e8c52518d98d011d1cc217bd804ccc416e3eaa8a5587cfecfdc5",
45
45
  "files": [
46
46
  {
47
47
  "path": "i18n.ts",
48
- "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Shop\",\n free: \"Free\",\n soldOut: \"Sold out\",\n refreshesIn: \"Refreshes in\",\n readyIn: \"Ready in\",\n confirmTitle: \"Confirm purchase\",\n confirmBuy: \"Buy for\",\n confirmFree: \"Take for free\",\n emptyShop: \"The shop is empty. Add offers in LiveOps → Store.\",\n left: \"left\",\n retry: \"Retry\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Магазин\",\n free: \"Бесплатно\",\n soldOut: \"Распродано\",\n refreshesIn: \"Обновится через\",\n readyIn: \"Доступно через\",\n confirmTitle: \"Подтвердите покупку\",\n confirmBuy: \"Купить за\",\n confirmFree: \"Забрать бесплатно\",\n emptyShop: \"В магазине пусто. Добавьте товары в LiveOps → Store.\",\n left: \"осталось\",\n retry: \"Повторить\",\n});\n"
48
+ "content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Shop\",\n free: \"Free\",\n soldOut: \"Sold out\",\n refreshesIn: \"Refreshes in\",\n readyIn: \"Ready in\",\n confirmTitle: \"Confirm purchase\",\n confirmBuy: \"Buy for\",\n confirmFree: \"Take for free\",\n emptyShop: \"The shop is empty. Add offers in LiveOps → Store.\",\n left: \"left\",\n retry: \"Retry\",\n currencyName: \"Aspis\",\n creditsConfirmHint: \"Confirm the purchase on idosgames.com — the game cannot spend your Aspis without you.\",\n creditsConfirmOpen: \"Confirm on idosgames.com\",\n creditsWaiting: \"Waiting for confirmation…\",\n creditsLogin: \"Sign in with your iDos Games account to pay with Aspis.\",\n creditsNotEnough: \"Not enough Aspis.\",\n creditsTopUp: \"Top up Aspis\",\n creditsDeclined: \"Purchase cancelled.\",\n creditsExpired: \"The request expired — try again.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Магазин\",\n free: \"Бесплатно\",\n soldOut: \"Распродано\",\n refreshesIn: \"Обновится через\",\n readyIn: \"Доступно через\",\n confirmTitle: \"Подтвердите покупку\",\n confirmBuy: \"Купить за\",\n confirmFree: \"Забрать бесплатно\",\n emptyShop: \"В магазине пусто. Добавьте товары в LiveOps → Store.\",\n left: \"осталось\",\n retry: \"Повторить\",\n currencyName: \"Aspis\",\n creditsConfirmHint: \"Подтвердите покупку на idosgames.com — без вас игра не может тратить ваши Aspis.\",\n creditsConfirmOpen: \"Подтвердить на idosgames.com\",\n creditsWaiting: \"Ждём подтверждения…\",\n creditsLogin: \"Войдите через аккаунт iDos Games, чтобы платить Aspis.\",\n creditsNotEnough: \"Не хватает Aspis.\",\n creditsTopUp: \"Пополнить Aspis\",\n creditsDeclined: \"Покупка отменена.\",\n creditsExpired: \"Срок заявки истёк — попробуйте ещё раз.\",\n});\n"
49
49
  },
50
50
  {
51
51
  "path": "index.ts",
@@ -53,15 +53,19 @@
53
53
  },
54
54
  {
55
55
  "path": "model.ts",
56
- "content": "// Pure helpers of the shop screen — covered by model.test.ts.\n\nimport type {\n GetStorefrontResponse,\n StorefrontOfferView,\n StorefrontSectionView,\n StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { costLines } from \"@idosgames/react/ui\";\n\nexport function sortedSections(\n store: { Sections?: StorefrontSectionView[] | null } | null | undefined,\n): StorefrontSectionView[] {\n return (store?.Sections ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\nexport function slotsOf(section: StorefrontSectionView): StorefrontSlotView[] {\n return (section.Slots ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** When the first card of a section refreshes — the section's countdown. */\nexport function earliestRefresh(section: StorefrontSectionView): string | null {\n const times = slotsOf(section)\n .map((s) => s.RefreshesAtUtc)\n .filter((x): x is string => typeof x === \"string\")\n .sort();\n return times[0] ?? null;\n}\n\nexport function isFree(offer: StorefrontOfferView): boolean {\n const option = offer.PriceOptions?.[0];\n return option?.IsFree === true || costLines(option?.Cost).length === 0;\n}\n\n/** Offers the player can take right now (not sold out, not waiting for their window). */\nexport function isAvailable(offer: StorefrontOfferView): boolean {\n return offer.State?.SoldOut !== true && !offer.State?.AvailableAtUtc;\n}\n\n/** The badge of the shop: free offers waiting to be taken (the daily gift). */\nexport function freeOffersWaiting(\n front: GetStorefrontResponse | null | undefined,\n): number {\n let n = 0;\n for (const store of front?.Stores ?? [])\n for (const section of store.Sections ?? [])\n for (const slot of section.Slots ?? [])\n if (slot.Offer && isFree(slot.Offer) && isAvailable(slot.Offer)) n++;\n return n;\n}\n\n/** Stores by their order; the first is the default tab. */\nexport function sortedStores(\n front: GetStorefrontResponse | null | undefined,\n): NonNullable<GetStorefrontResponse[\"Stores\"]> {\n return (front?.Stores ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** Whether the storefront has any offer — until it does, the lobby hides the Shop tab. */\nexport function hasOffers(\n front: GetStorefrontResponse | null | undefined,\n): boolean {\n return (front?.Stores ?? []).some((store) =>\n (store.Sections ?? []).some((section) =>\n (section.Slots ?? []).some((slot) => slot.Offer != null),\n ),\n );\n}\n"
56
+ "content": "// Pure helpers of the shop screen — covered by model.test.ts.\n\nimport type {\n GetStorefrontResponse,\n StorefrontOfferView,\n StorefrontSectionView,\n StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { costLines } from \"@idosgames/react/ui\";\n\nexport function sortedSections(\n store: { Sections?: StorefrontSectionView[] | null } | null | undefined,\n): StorefrontSectionView[] {\n return (store?.Sections ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\nexport function slotsOf(section: StorefrontSectionView): StorefrontSlotView[] {\n return (section.Slots ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** When the first card of a section refreshes — the section's countdown. */\nexport function earliestRefresh(section: StorefrontSectionView): string | null {\n const times = slotsOf(section)\n .map((s) => s.RefreshesAtUtc)\n .filter((x): x is string => typeof x === \"string\")\n .sort();\n return times[0] ?? null;\n}\n\n/**\n * Price in Credits (platform currency) of the first option, or null when the offer is not sold for Credits. A Credits price\n * is not a game resource: the card shows it as text, and the purchase goes through the player's confirmation on\n * idosgames.com (`client.store.purchaseWithCredits`).\n */\nexport function creditsPriceOf(offer: StorefrontOfferView): number | null {\n const option = offer.PriceOptions?.[0];\n return option?.IsCreditsPaid ? Number(option.CreditsPrice ?? 0) : null;\n}\n\n/** Price lines in game resources — without the Credits entry (it is drawn by `creditsPriceOf`). */\nexport function resourcePrice(offer: StorefrontOfferView) {\n return costLines(offer.PriceOptions?.[0]?.Cost).filter(\n (line) => !(line.kind === \"other\" && line.id === \"PlatformCredits\"),\n );\n}\n\nexport function isFree(offer: StorefrontOfferView): boolean {\n if (creditsPriceOf(offer) != null) return false;\n const option = offer.PriceOptions?.[0];\n return option?.IsFree === true || costLines(option?.Cost).length === 0;\n}\n\n/** Offers the player can take right now (not sold out, not waiting for their window). */\nexport function isAvailable(offer: StorefrontOfferView): boolean {\n return offer.State?.SoldOut !== true && !offer.State?.AvailableAtUtc;\n}\n\n/** The badge of the shop: free offers waiting to be taken (the daily gift). */\nexport function freeOffersWaiting(\n front: GetStorefrontResponse | null | undefined,\n): number {\n let n = 0;\n for (const store of front?.Stores ?? [])\n for (const section of store.Sections ?? [])\n for (const slot of section.Slots ?? [])\n if (slot.Offer && isFree(slot.Offer) && isAvailable(slot.Offer)) n++;\n return n;\n}\n\n/** Stores by their order; the first is the default tab. */\nexport function sortedStores(\n front: GetStorefrontResponse | null | undefined,\n): NonNullable<GetStorefrontResponse[\"Stores\"]> {\n return (front?.Stores ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** Whether the storefront has any offer — until it does, the lobby hides the Shop tab. */\nexport function hasOffers(\n front: GetStorefrontResponse | null | undefined,\n): boolean {\n return (front?.Stores ?? []).some((store) =>\n (store.Sections ?? []).some((section) =>\n (section.Slots ?? []).some((slot) => slot.Offer != null),\n ),\n );\n}\n"
57
57
  },
58
58
  {
59
59
  "path": "module.ts",
60
60
  "content": "import {\n defineModule,\n type Module,\n type ModuleContext,\n} from \"@idosgames/module-sdk\";\nimport { StoreScreen } from \"./StoreScreen\";\nimport { freeOffersWaiting, hasOffers } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The shop as a feature of the game (ctx.features): the lobby shows it as a main tab once the\n// storefront has offers. Its badge counts free offers waiting to be taken (the daily gift), kept\n// fresh by an invisible always-on panel — the screen itself is mounted only while open.\nexport const storeModule: Module = defineModule({\n id: \"store\",\n meta: { name: \"Shop\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"store\",\n label: () => t(\"title\"),\n icon: \"shop\",\n group: \"economy\",\n order: 10,\n primary: true,\n available: false,\n Screen: StoreScreen,\n });\n watchBadge(ctx);\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"store\",\n badge: ctx.features.get(\"store\")?.badge ?? 0,\n }),\n describeActions: {},\n });\n },\n});\n\nfunction watchBadge(ctx: ModuleContext): void {\n const { client, features } = ctx;\n ctx.effect(() => {\n const update = () => {\n const front = client.store.cachedStorefront;\n features.setAvailable(\n \"store\",\n hasOffers(front),\n \"the storefront has no offers (Store section)\",\n );\n features.setBadge(\"store\", freeOffersWaiting(front));\n };\n // A free gift comes back with the next day: look again every few minutes.\n ctx.every(5 * 60_000, () => void client.store.getStorefront().then(update));\n // The badge is counted from the STOREFRONT, and after a purchase the screen re-reads it: the\n // purchase's own state update comes before that, with the old storefront still cached — so the\n // gift just taken kept the badge lit until the next re-read here. Count again on every load.\n const offs = [\n client.on(\"user:anyUpdated\", update),\n client.on(\"store:storefrontLoaded\", update),\n ];\n return () => {\n for (const off of offs) off();\n };\n });\n}\n"
61
61
  },
62
+ {
63
+ "path": "PlatformCurrencyIcon.tsx",
64
+ "content": "import { useId } from \"react\";\r\n\r\n/**\r\n * Aspis icon (Credits in the code) — the platform currency (owner's decision 26.10.2026: a currency has an icon). ⚠ A copy of the\r\n * dashboard's and the website's drawing (`platform.idosgames.com/components/core/billing/PlatformCurrencyIcon.tsx`,\r\n * `iDosGames_com/components/v2/credits/PlatformCurrencyIcon.tsx`): change all three, or one currency looks different in\r\n * the game and on the site where the player confirms the purchase.\r\n */\r\nexport function PlatformCurrencyIcon({ size = 18 }: { size?: number }) {\r\n // A unique gradient id per instance: two icons sharing one id share one gradient.\r\n const id = useId().replace(/:/g, \"\");\r\n return (\r\n <svg\r\n width={size}\r\n height={size}\r\n viewBox=\"0 0 24 24\"\r\n aria-hidden=\"true\"\r\n style={{ flexShrink: 0 }}\r\n >\r\n <defs>\r\n <linearGradient id={`cg${id}`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">\r\n <stop offset=\"0\" stopColor=\"#FFE58A\" />\r\n <stop offset=\"1\" stopColor=\"#E08E00\" />\r\n </linearGradient>\r\n <clipPath id={`cc${id}`}>\r\n <circle cx=\"12\" cy=\"12\" r=\"9.3\" />\r\n </clipPath>\r\n </defs>\r\n <circle cx=\"12\" cy=\"12\" r=\"11\" fill={`url(#cg${id})`} />\r\n <circle cx=\"12\" cy=\"12\" r=\"10.1\" fill=\"none\" stroke=\"#9A5A00\" strokeWidth=\"1.7\" />\r\n <path\r\n d=\"M2.2 20 L12 10.6 L21.8 20\"\r\n clipPath={`url(#cc${id})`}\r\n fill=\"none\"\r\n stroke=\"#9B1C12\"\r\n strokeWidth=\"3.1\"\r\n strokeLinecap=\"butt\"\r\n strokeLinejoin=\"miter\"\r\n />\r\n </svg>\r\n );\r\n}\r\n\r\n/** \"[icon] 100 Aspis\" — the price of an offer sold for Aspis (Credits in the code). */\r\nexport function CreditsPrice({ amount, label, size = 18 }: { amount: number; label: string; size?: number }) {\r\n return (\r\n <span style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6 }}>\r\n <PlatformCurrencyIcon size={size} />\r\n {amount} {label}\r\n </span>\r\n );\r\n}\r\n"
65
+ },
62
66
  {
63
67
  "path": "StoreScreen.tsx",
64
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport {\n OFFER_NOT_IN_ROTATION,\n type GetStorefrontResponse,\n type StorefrontOfferView,\n type StorefrontSectionView,\n type StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n SoldOutStamp,\n Spinner,\n Tabs,\n Timer,\n TONES,\n canAfford,\n costLines,\n countdown,\n errorText,\n grantLines,\n grantedBy,\n inkFrame,\n inked,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ButtonTone,\n type ResourceLine,\n tokenGlyph,\n} from \"@idosgames/react/ui\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport {\n earliestRefresh,\n isFree,\n slotsOf,\n sortedSections,\n sortedStores,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n/** The ribbon colour of each section in turn, as the sheet's shop (\"Daily Shop\", \"AD Shop\", \"Gem Pack\"…). */\nconst SECTION_TONES: ButtonTone[] = [\n \"red\",\n \"green\",\n \"purple\",\n \"gold\",\n \"blue\",\n \"orange\",\n];\n\n// The shop, drawn from the storefront RESOLVED for this player (client.store.getStorefront — never\n// from the raw config): rotation, limits, badges and timers are computed by the server. Layout of the\n// Unity shop window (Alikhan/Shop): several stores as tabs, the first section of a store is a\n// carousel, the rest are grids; a tap opens the confirmation popup; a purchase shows what was granted\n// and the rewards fly to their counters.\n\ninterface Picked {\n storeID: string;\n section: StorefrontSectionView;\n slot: StorefrontSlotView;\n offer: StorefrontOfferView;\n}\n\nexport function StoreScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [front, setFront] = useState<GetStorefrontResponse | null>(\n client.store.cachedStorefront,\n );\n const [loading, setLoading] = useState(front === null);\n const [failed, setFailed] = useState<string | null>(null);\n const [storeID, setStoreID] = useState<string | null>(\n typeof args?.storeID === \"string\" ? args.storeID : null,\n );\n const [picked, setPicked] = useState<Picked | null>(null);\n // Server clock offset: timers count from the SERVER time, not the device clock.\n const [skewMs, setSkewMs] = useState(0);\n const now = useNow() + skewMs;\n\n const load = useCallback(async () => {\n const res = await client.store.getStorefront();\n setLoading(false);\n if (!res.ok) {\n // A title without a Store section answers \"not found\" — that is an empty shop, not a failure.\n setFailed(\n /not found|not configured/i.test(res.error ?? \"\")\n ? t(\"emptyShop\")\n : errorText(res.error),\n );\n return;\n }\n setFailed(null);\n setFront(res.data);\n setSkewMs(new Date(res.data.ServerTimeUtc).getTime() - Date.now());\n }, [client]);\n\n useEffect(() => {\n void load();\n }, [load]);\n\n // One reload when the nearest card refreshes, instead of polling.\n useEffect(() => {\n const next = client.store.nextRefreshAtUtc();\n if (!next) return;\n const ms = new Date(next).getTime() - (Date.now() + skewMs);\n if (ms <= 0 || ms > 24 * 3600_000) return;\n const id = setTimeout(() => void load(), ms + 500);\n return () => clearTimeout(id);\n }, [front, client, load, skewMs]);\n\n if (loading)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (failed)\n return (\n <EmptyState\n glyph=\"shop\"\n text={failed}\n action={<Button onClick={() => void load()}>{t(\"retry\")}</Button>}\n />\n );\n\n const stores = sortedStores(front);\n const store = stores.find((s) => s.StoreID === storeID) ?? stores[0];\n const sections = sortedSections(store);\n if (!store || sections.length === 0)\n return <EmptyState glyph=\"shop\" text={t(\"emptyShop\")} />;\n const [first, ...rest] = sections;\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n <Tabs\n tabs={stores.map((s) => ({\n id: s.StoreID,\n label: s.Identity?.DisplayName ?? s.StoreID,\n }))}\n value={store.StoreID}\n onChange={setStoreID}\n />\n {first ? (\n <div key={`${store.StoreID}:${first.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[0]}\n right={\n <Timer\n at={earliestRefresh(first)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {first.Identity?.DisplayName ?? first.SectionID}\n </SectionTitle>\n <div\n className=\"idos-scroll\"\n style={{\n display: \"flex\",\n gap: 12,\n overflowX: \"auto\",\n padding: \"4px 4px 12px\",\n scrollSnapType: \"x mandatory\",\n }}\n >\n {slotsOf(first).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n wide\n index={i}\n onPick={(offer) =>\n setPicked({\n storeID: store.StoreID,\n section: first,\n slot,\n offer,\n })\n }\n />\n ))}\n </div>\n </div>\n ) : null}\n {rest.map((section, i) => (\n <div key={`${store.StoreID}:${section.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[(i + 1) % SECTION_TONES.length]}\n right={\n <Timer\n at={earliestRefresh(section)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {section.Identity?.DisplayName ?? section.SectionID}\n </SectionTitle>\n <OfferGrid>\n {slotsOf(section).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n index={i}\n onPick={(offer) =>\n setPicked({ storeID: store.StoreID, section, slot, offer })\n }\n />\n ))}\n </OfferGrid>\n </div>\n ))}\n {picked ? (\n <ConfirmPurchase\n picked={picked}\n onReload={load}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OfferGrid({ children }: { children: ReactNode }): ReactNode {\n const layout = useGameLayout();\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 150 : 180}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n }}\n >\n {children}\n </div>\n );\n}\n\nfunction OfferCard({\n slot,\n now,\n wide = false,\n index,\n onPick,\n}: {\n slot: StorefrontSlotView;\n now: number;\n wide?: boolean;\n index: number;\n onPick: (offer: StorefrontOfferView) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const state = useUserState();\n const offer = slot.Offer;\n const width = wide ? (layout === \"phone\" ? 170 : 200) : undefined;\n\n // An EMPTY slot is still drawn — the shelf must not jump when a window closes.\n if (!offer)\n return (\n <div\n style={{\n ...panel,\n width,\n minHeight: 200,\n display: \"grid\",\n placeItems: \"center\",\n opacity: 0.55,\n scrollSnapAlign: \"start\",\n flex: \"none\",\n ...stagger(index),\n }}\n >\n <Icon glyph=\"lock\" size={40} />\n </div>\n );\n\n const rewards = grantLines(offer.Rewards);\n const main = rewards[0];\n const price = costLines(offer.PriceOptions?.[0]?.Cost);\n const soldOut = offer.State?.SoldOut === true;\n const readyIn = countdown(offer.State?.AvailableAtUtc ?? null, now);\n const free = isFree(offer);\n const disabled = soldOut || readyIn !== null;\n const affordable = canAfford(price, state?.InventoryV2?.VirtualCurrencies);\n const name = catalog.localize(offer.Identity?.DisplayName ?? offer.OfferID);\n const remaining = offer.State?.RemainingToday ?? offer.State?.RemainingTotal;\n\n // The sheet's shop card: a light-blue plate in the ink frame (green when free), the name on top,\n // the prize big in the middle, the price at the bottom; a sold-out card is dimmed under the stamp.\n const [top, deep] = TONES[free ? \"green\" : \"blue\"];\n return (\n <div\n className={free && !disabled ? \"idos-shine\" : undefined}\n style={{\n position: \"relative\",\n width,\n flex: wide ? \"none\" : undefined,\n scrollSnapAlign: \"start\",\n padding: \"10px 10px 12px\",\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n borderRadius: 12,\n border: inkFrame,\n background: `linear-gradient(180deg, color-mix(in srgb, ${top} 55%, #fff) 0%, ${top} 38%, ${deep} 100%)`,\n boxShadow: `inset 0 -4px 0 rgba(0,0,0,.2), inset 0 2px 0 rgba(255,255,255,.45), 0 4px 0 ${v.shadow}`,\n ...stagger(index),\n }}\n >\n <div\n style={{\n ...inked,\n fontSize: 16,\n textAlign: \"center\",\n minHeight: 36,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n {name}\n </div>\n <div\n className={free && !disabled ? \"idos-bounce\" : undefined}\n style={{\n width: 84,\n height: 84,\n display: \"grid\",\n placeItems: \"center\",\n borderRadius: 20,\n background:\n \"radial-gradient(circle, rgba(255,255,255,.18), transparent 70%)\",\n }}\n >\n <Icon\n glyph={\n main\n ? main.kind === \"token\"\n ? tokenGlyph(main)\n : catalog.iconOf(main)\n : \"gift\"\n }\n size={62}\n />\n </div>\n <div style={{ minHeight: 22 }}>\n <ResourceList lines={rewards} />\n </div>\n {remaining != null ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"left\")}: {remaining}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={disabled}\n attract={free && !disabled}\n onClick={() => onPick(offer)}\n style={{ width: \"100%\", visibility: soldOut ? \"hidden\" : undefined }}\n >\n {soldOut ? (\n t(\"soldOut\")\n ) : readyIn ? (\n `${t(\"readyIn\")} ${readyIn}`\n ) : free ? (\n t(\"free\")\n ) : (\n <ResourceList lines={price} size={20} dimIf={() => !affordable} />\n )}\n </Button>\n {soldOut ? (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 9,\n background: `color-mix(in srgb, ${v.bgBottom} 55%, transparent)`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <SoldOutStamp />\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction ConfirmPurchase({\n picked,\n onReload,\n onClose,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const rewards = grantLines(picked.offer.Rewards);\n const name = catalog.localize(\n picked.offer.Identity?.DisplayName ?? picked.offer.OfferID,\n );\n return (\n <Popup title={t(\"confirmTitle\")} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 17, textAlign: \"center\" }}>\n {name}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 14,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rewards.map((line) => (\n <div\n key={`${line.kind}:${line.id}`}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}\n >\n <Icon\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={54}\n />\n <span style={{ ...outlined, fontSize: 17 }}>×{line.amount}</span>\n </div>\n ))}\n </div>\n </div>\n <BuyButton picked={picked} onReload={onReload} />\n </Popup>\n );\n}\n\nfunction BuyButton({\n picked,\n onReload,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const option = picked.offer.PriceOptions?.[0];\n const price: ResourceLine[] = costLines(option?.Cost);\n const free = isFree(picked.offer);\n\n const buy = async () => {\n setBusy(true);\n const res = await client.store.purchase(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot: {\n storeID: picked.storeID,\n sectionID: picked.section.SectionID,\n slotID: picked.slot.SlotID,\n },\n });\n setBusy(false);\n if (!res.ok) {\n if (res.error === OFFER_NOT_IN_ROTATION) {\n close();\n await onReload(); // the shelf reshuffled — redraw silently\n return;\n }\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"purchase\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : grantLines(picked.offer.Rewards));\n await onReload();\n };\n\n return (\n <div ref={ref}>\n <Button\n tone={free ? \"green\" : \"gold\"}\n busy={busy}\n attract\n onClick={() => void buy()}\n style={{ width: \"100%\" }}\n size=\"lg\"\n >\n {free ? (\n t(\"confirmFree\")\n ) : (\n <>\n {t(\"confirmBuy\")} <ResourceList lines={price} size={20} />\n </>\n )}\n </Button>\n </div>\n );\n}\n"
68
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport {\n CreditsError,\n OFFER_NOT_IN_ROTATION,\n type CreditsConfirmationView,\n type GetStorefrontResponse,\n type StorefrontOfferView,\n type StorefrontSectionView,\n type StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport { CreditsPrice } from \"./PlatformCurrencyIcon\";\nimport {\n Button,\n Center,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n SectionTitle,\n SoldOutStamp,\n Spinner,\n Tabs,\n Timer,\n TONES,\n canAfford,\n countdown,\n errorText,\n grantLines,\n grantedBy,\n inkFrame,\n inked,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n type ButtonTone,\n type ResourceLine,\n tokenGlyph,\n} from \"@idosgames/react/ui\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport {\n creditsPriceOf,\n earliestRefresh,\n isFree,\n resourcePrice,\n slotsOf,\n sortedSections,\n sortedStores,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n/** The ribbon colour of each section in turn, as the sheet's shop (\"Daily Shop\", \"AD Shop\", \"Gem Pack\"…). */\nconst SECTION_TONES: ButtonTone[] = [\n \"red\",\n \"green\",\n \"purple\",\n \"gold\",\n \"blue\",\n \"orange\",\n];\n\n// The shop, drawn from the storefront RESOLVED for this player (client.store.getStorefront — never\n// from the raw config): rotation, limits, badges and timers are computed by the server. Layout of the\n// Unity shop window (Alikhan/Shop): several stores as tabs, the first section of a store is a\n// carousel, the rest are grids; a tap opens the confirmation popup; a purchase shows what was granted\n// and the rewards fly to their counters.\n\ninterface Picked {\n storeID: string;\n section: StorefrontSectionView;\n slot: StorefrontSlotView;\n offer: StorefrontOfferView;\n}\n\nexport function StoreScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const [front, setFront] = useState<GetStorefrontResponse | null>(\n client.store.cachedStorefront,\n );\n const [loading, setLoading] = useState(front === null);\n const [failed, setFailed] = useState<string | null>(null);\n const [storeID, setStoreID] = useState<string | null>(\n typeof args?.storeID === \"string\" ? args.storeID : null,\n );\n const [picked, setPicked] = useState<Picked | null>(null);\n // Server clock offset: timers count from the SERVER time, not the device clock.\n const [skewMs, setSkewMs] = useState(0);\n const now = useNow() + skewMs;\n\n const load = useCallback(async () => {\n const res = await client.store.getStorefront();\n setLoading(false);\n if (!res.ok) {\n // A title without a Store section answers \"not found\" — that is an empty shop, not a failure.\n setFailed(\n /not found|not configured/i.test(res.error ?? \"\")\n ? t(\"emptyShop\")\n : errorText(res.error),\n );\n return;\n }\n setFailed(null);\n setFront(res.data);\n setSkewMs(new Date(res.data.ServerTimeUtc).getTime() - Date.now());\n }, [client]);\n\n useEffect(() => {\n void load();\n }, [load]);\n\n // One reload when the nearest card refreshes, instead of polling.\n useEffect(() => {\n const next = client.store.nextRefreshAtUtc();\n if (!next) return;\n const ms = new Date(next).getTime() - (Date.now() + skewMs);\n if (ms <= 0 || ms > 24 * 3600_000) return;\n const id = setTimeout(() => void load(), ms + 500);\n return () => clearTimeout(id);\n }, [front, client, load, skewMs]);\n\n if (loading)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (failed)\n return (\n <EmptyState\n glyph=\"shop\"\n text={failed}\n action={<Button onClick={() => void load()}>{t(\"retry\")}</Button>}\n />\n );\n\n const stores = sortedStores(front);\n const store = stores.find((s) => s.StoreID === storeID) ?? stores[0];\n const sections = sortedSections(store);\n if (!store || sections.length === 0)\n return <EmptyState glyph=\"shop\" text={t(\"emptyShop\")} />;\n const [first, ...rest] = sections;\n\n return (\n <div style={{ display: \"grid\", gap: 18 }}>\n <Tabs\n tabs={stores.map((s) => ({\n id: s.StoreID,\n label: s.Identity?.DisplayName ?? s.StoreID,\n }))}\n value={store.StoreID}\n onChange={setStoreID}\n />\n {first ? (\n <div key={`${store.StoreID}:${first.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[0]}\n right={\n <Timer\n at={earliestRefresh(first)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {first.Identity?.DisplayName ?? first.SectionID}\n </SectionTitle>\n <div\n className=\"idos-scroll\"\n style={{\n display: \"flex\",\n gap: 12,\n overflowX: \"auto\",\n padding: \"4px 4px 12px\",\n scrollSnapType: \"x mandatory\",\n }}\n >\n {slotsOf(first).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n wide\n index={i}\n onPick={(offer) =>\n setPicked({\n storeID: store.StoreID,\n section: first,\n slot,\n offer,\n })\n }\n />\n ))}\n </div>\n </div>\n ) : null}\n {rest.map((section, i) => (\n <div key={`${store.StoreID}:${section.SectionID}`}>\n <SectionTitle\n tone={SECTION_TONES[(i + 1) % SECTION_TONES.length]}\n right={\n <Timer\n at={earliestRefresh(section)}\n now={now}\n label={t(\"refreshesIn\")}\n />\n }\n >\n {section.Identity?.DisplayName ?? section.SectionID}\n </SectionTitle>\n <OfferGrid>\n {slotsOf(section).map((slot, i) => (\n <OfferCard\n key={slot.SlotID}\n slot={slot}\n now={now}\n index={i}\n onPick={(offer) =>\n setPicked({ storeID: store.StoreID, section, slot, offer })\n }\n />\n ))}\n </OfferGrid>\n </div>\n ))}\n {picked ? (\n <ConfirmPurchase\n picked={picked}\n onReload={load}\n onClose={() => setPicked(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction OfferGrid({ children }: { children: ReactNode }): ReactNode {\n const layout = useGameLayout();\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${layout === \"phone\" ? 150 : 180}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n }}\n >\n {children}\n </div>\n );\n}\n\nfunction OfferCard({\n slot,\n now,\n wide = false,\n index,\n onPick,\n}: {\n slot: StorefrontSlotView;\n now: number;\n wide?: boolean;\n index: number;\n onPick: (offer: StorefrontOfferView) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const state = useUserState();\n const offer = slot.Offer;\n const width = wide ? (layout === \"phone\" ? 170 : 200) : undefined;\n\n // An EMPTY slot is still drawn — the shelf must not jump when a window closes.\n if (!offer)\n return (\n <div\n style={{\n ...panel,\n width,\n minHeight: 200,\n display: \"grid\",\n placeItems: \"center\",\n opacity: 0.55,\n scrollSnapAlign: \"start\",\n flex: \"none\",\n ...stagger(index),\n }}\n >\n <Icon glyph=\"lock\" size={40} />\n </div>\n );\n\n const rewards = grantLines(offer.Rewards);\n const main = rewards[0];\n const price = resourcePrice(offer);\n const creditsPrice = creditsPriceOf(offer);\n const soldOut = offer.State?.SoldOut === true;\n const readyIn = countdown(offer.State?.AvailableAtUtc ?? null, now);\n const free = isFree(offer);\n const disabled = soldOut || readyIn !== null;\n const affordable = canAfford(price, state?.InventoryV2?.VirtualCurrencies);\n const name = catalog.localize(offer.Identity?.DisplayName ?? offer.OfferID);\n const remaining = offer.State?.RemainingToday ?? offer.State?.RemainingTotal;\n\n // The sheet's shop card: a light-blue plate in the ink frame (green when free), the name on top,\n // the prize big in the middle, the price at the bottom; a sold-out card is dimmed under the stamp.\n const [top, deep] = TONES[free ? \"green\" : \"blue\"];\n return (\n <div\n className={free && !disabled ? \"idos-shine\" : undefined}\n style={{\n position: \"relative\",\n width,\n flex: wide ? \"none\" : undefined,\n scrollSnapAlign: \"start\",\n padding: \"10px 10px 12px\",\n display: \"grid\",\n gap: 8,\n justifyItems: \"center\",\n borderRadius: 12,\n border: inkFrame,\n background: `linear-gradient(180deg, color-mix(in srgb, ${top} 55%, #fff) 0%, ${top} 38%, ${deep} 100%)`,\n boxShadow: `inset 0 -4px 0 rgba(0,0,0,.2), inset 0 2px 0 rgba(255,255,255,.45), 0 4px 0 ${v.shadow}`,\n ...stagger(index),\n }}\n >\n <div\n style={{\n ...inked,\n fontSize: 16,\n textAlign: \"center\",\n minHeight: 36,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n {name}\n </div>\n <div\n className={free && !disabled ? \"idos-bounce\" : undefined}\n style={{\n width: 84,\n height: 84,\n display: \"grid\",\n placeItems: \"center\",\n borderRadius: 20,\n background:\n \"radial-gradient(circle, rgba(255,255,255,.18), transparent 70%)\",\n }}\n >\n <Icon\n glyph={\n main\n ? main.kind === \"token\"\n ? tokenGlyph(main)\n : catalog.iconOf(main)\n : \"gift\"\n }\n size={62}\n />\n </div>\n <div style={{ minHeight: 22 }}>\n <ResourceList lines={rewards} />\n </div>\n {remaining != null ? (\n <div\n style={{\n ...outlined,\n fontSize: 11,\n color: v.textDim,\n fontWeight: 700,\n }}\n >\n {t(\"left\")}: {remaining}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={disabled}\n attract={free && !disabled}\n onClick={() => onPick(offer)}\n style={{ width: \"100%\", visibility: soldOut ? \"hidden\" : undefined }}\n >\n {soldOut ? (\n t(\"soldOut\")\n ) : readyIn ? (\n `${t(\"readyIn\")} ${readyIn}`\n ) : free ? (\n t(\"free\")\n ) : creditsPrice != null ? (\n <CreditsPrice amount={creditsPrice} label={t(\"currencyName\")} size={20} />\n ) : (\n <ResourceList lines={price} size={20} dimIf={() => !affordable} />\n )}\n </Button>\n {soldOut ? (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n borderRadius: 9,\n background: `color-mix(in srgb, ${v.bgBottom} 55%, transparent)`,\n display: \"grid\",\n placeItems: \"center\",\n }}\n >\n <SoldOutStamp />\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction ConfirmPurchase({\n picked,\n onReload,\n onClose,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const rewards = grantLines(picked.offer.Rewards);\n const name = catalog.localize(\n picked.offer.Identity?.DisplayName ?? picked.offer.OfferID,\n );\n return (\n <Popup title={t(\"confirmTitle\")} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div style={{ ...outlined, fontSize: 17, textAlign: \"center\" }}>\n {name}\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 14,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {rewards.map((line) => (\n <div\n key={`${line.kind}:${line.id}`}\n style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}\n >\n <Icon\n glyph={\n line.kind === \"token\"\n ? tokenGlyph(line)\n : catalog.iconOf(line)\n }\n size={54}\n />\n <span style={{ ...outlined, fontSize: 17 }}>×{line.amount}</span>\n </div>\n ))}\n </div>\n </div>\n <BuyButton picked={picked} onReload={onReload} />\n </Popup>\n );\n}\n\nfunction BuyButton({\n picked,\n onReload,\n}: {\n picked: Picked;\n onReload: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const option = picked.offer.PriceOptions?.[0];\n const price: ResourceLine[] = resourcePrice(picked.offer);\n const creditsPrice = creditsPriceOf(picked.offer);\n const free = isFree(picked.offer);\n // Credits: the request waiting for the player on idosgames.com. Leaving the popup stops the wait.\n const [confirm, setConfirm] = useState<CreditsConfirmationView | null>(null);\n const abort = useRef<AbortController | null>(null);\n useEffect(() => () => abort.current?.abort(), []);\n\n const creditsError = (error: string | undefined): string => {\n if (error === CreditsError.LoginRequired) return t(\"creditsLogin\");\n if (error === CreditsError.NotEnough) return t(\"creditsNotEnough\");\n if (error === CreditsError.ConfirmationDeclined) return t(\"creditsDeclined\");\n if (error === CreditsError.ConfirmationExpired) return t(\"creditsExpired\");\n return errorText(error);\n };\n\n const buy = async () => {\n setBusy(true);\n const slot = {\n storeID: picked.storeID,\n sectionID: picked.section.SectionID,\n slotID: picked.slot.SlotID,\n };\n abort.current = new AbortController();\n const res =\n creditsPrice != null\n ? await client.store.purchaseWithCredits(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot,\n onConfirmation: setConfirm,\n signal: abort.current.signal,\n })\n : await client.store.purchase(picked.offer.OfferID, 1, {\n selectedOptionID: option?.OptionID,\n slot,\n });\n setBusy(false);\n setConfirm(null);\n if (!res.ok) {\n if (res.error === OFFER_NOT_IN_ROTATION) {\n close();\n await onReload(); // the shelf reshuffled — redraw silently\n return;\n }\n if (res.error === \"CREDITS_CONFIRMATION_CANCELLED\") return;\n if (res.error === CreditsError.NotEnough) {\n const balance = await client.store.getCreditsBalance();\n const url = balance.ok ? balance.data.TopUpUrl : null;\n if (url) window.open(url, \"_blank\", \"noopener\");\n }\n toast(creditsPrice != null ? creditsError(res.error) : errorText(res.error), \"error\");\n return;\n }\n play(\"purchase\");\n kit.fx.burst(ref.current);\n close();\n const granted = grantedBy(res.data);\n celebrate(granted.length > 0 ? granted : grantLines(picked.offer.Rewards));\n await onReload();\n };\n\n if (confirm) {\n // A link, not window.open after an await: the browser blocks popups that are not a direct tap.\n return (\n <div ref={ref} style={{ display: \"grid\", gap: 10, textAlign: \"center\" }}>\n <span style={{ ...outlined, fontSize: 14 }}>{t(\"creditsConfirmHint\")}</span>\n <a\n href={confirm.Url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n style={{ textDecoration: \"none\" }}\n >\n <Button tone=\"gold\" size=\"lg\" style={{ width: \"100%\" }}>\n {t(\"creditsConfirmOpen\")}\n </Button>\n </a>\n <span style={{ ...outlined, fontSize: 13, opacity: 0.8 }}>{t(\"creditsWaiting\")}</span>\n </div>\n );\n }\n\n return (\n <div ref={ref}>\n <Button\n tone={free ? \"green\" : \"gold\"}\n busy={busy}\n attract\n onClick={() => void buy()}\n style={{ width: \"100%\" }}\n size=\"lg\"\n >\n {free ? (\n t(\"confirmFree\")\n ) : creditsPrice != null ? (\n <>\n {t(\"confirmBuy\")} <CreditsPrice amount={creditsPrice} label={t(\"currencyName\")} size={20} />\n </>\n ) : (\n <>\n {t(\"confirmBuy\")} <ResourceList lines={price} size={20} />\n </>\n )}\n </Button>\n </div>\n );\n}\n"
65
69
  },
66
70
  {
67
71
  "path": "module.meta.json",
@@ -32,7 +32,7 @@
32
32
  "version": "0.1.3"
33
33
  },
34
34
  "dependencies": {
35
- "@idosgames/core": "0.23.0",
35
+ "@idosgames/core": "0.24.0",
36
36
  "@idosgames/module-sdk": "0.13.0",
37
37
  "@idosgames/react": "0.6.0",
38
38
  "react": "19.2.7"
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@idosgames/characters": "0.1.1",
58
- "@idosgames/core": "0.23.0",
58
+ "@idosgames/core": "0.24.0",
59
59
  "@idosgames/module-sdk": "0.13.0",
60
60
  "@idosgames/react": "0.6.0",
61
61
  "@idosgames/timeline": "0.1.0",
@@ -85,7 +85,7 @@
85
85
  },
86
86
  "dependencies": {
87
87
  "@idosgames/characters": "0.1.1",
88
- "@idosgames/core": "0.23.0",
88
+ "@idosgames/core": "0.24.0",
89
89
  "@idosgames/module-sdk": "0.13.0",
90
90
  "@idosgames/react": "0.6.0",
91
91
  "react": "19.2.7",
@@ -39,7 +39,7 @@
39
39
  "version": "0.1.3"
40
40
  },
41
41
  "dependencies": {
42
- "@idosgames/core": "0.23.0",
42
+ "@idosgames/core": "0.24.0",
43
43
  "@idosgames/module-sdk": "0.13.0",
44
44
  "@idosgames/react": "0.6.0",
45
45
  "react": "19.2.7"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "store-system",
3
3
  "description": "Build a store / shop screen in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.store (StoreService): load the RESOLVED storefront for the current player (rotating daily shops, sections, slots, badges, refresh timers, remaining limits), load the offer catalogue, and purchase one or many offers. Prices can be virtual currency, crypto, a real money store product, a rewarded-video ad credit, or free. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a shop screen, a daily rotating shop, gem packs, free/ad-paid slots, first-purchase badges, purchase-limit UI, or otherwise touches client.store, StoreService, GetStorefrontResponse, StoreDefinitions or offer purchasing — even if they don't name the module.",
4
- "content": "---\nname: store-system\ndescription: >-\n Build a store / shop screen in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.store (StoreService): load the RESOLVED\n storefront for the current player (rotating daily shops, sections, slots,\n badges, refresh timers, remaining limits), load the offer catalogue, and\n purchase one or many offers. Prices can be virtual currency, crypto, a real\n money store product, a rewarded-video ad credit, or free. Use this whenever\n the user is working in the iDosGames TS SDK or its game templates\n (board-game, idle-rpg) and wants a shop screen, a daily rotating shop, gem\n packs, free/ad-paid slots, first-purchase badges, purchase-limit UI, or\n otherwise touches client.store, StoreService, GetStorefrontResponse,\n StoreDefinitions or offer purchasing — even if they don't name the module.\n---\n\n# Store system (iDosGames TS SDK)\n\nA title sells **offers** (products) placed on **shelves**. The shop is a tree:\n\n```\nStore (storefront)\n└── Layout.Sections\n └── Slots ← a slot is a PLACE on the shelf\n └── the slot decides which offer stands in it\n```\n\n⚠⚠ **A slot is the only address a product can be bought through.** An offer\nlives in a flat catalogue (`StoreOffers`) and becomes buyable only by standing\nin a slot; a product placed in no slot **cannot be bought at all**. There is no\n\"list of stores\" on the product — that field was removed, because it was never\na right to buy: the purchase never checked it.\n\nEverything is **server-authoritative**. The client asks to purchase, the\nbackend validates price, windows, gates, limits **and the rotation roll**, then\nthe SDK mirrors the confirmed result into a local cache your UI reads.\n\n## ⚠ Draw `getStorefront()`, not `getDefinitions()`\n\nThis is the single most important rule of the module.\n\n`getDefinitions()` returns the **config** — identical for every player.\n`getStorefront()` returns the shop **resolved for this player**: rotation\nalready rolled, gates applied, badges and refresh timers computed, remaining\nlimits filled in. None of that can be derived on the client:\n\n- **Rotation** is recomputed by the server on every read and stored nowhere. A\n client rolling it itself would show two devices two different shops.\n- **Counters** (`PurchasedTotal`, remaining caps, cooldown) belong to the\n player and are not in the config.\n- **Gates** filter storefronts, sections, slots and pool entries per player.\n\nUse `getDefinitions()` only for catalogue-wide tooling (an admin view, a\nsearch). A shop screen never needs it.\n\n## Prices: five kinds, one shape\n\nPrice is always a `PriceOptions` dictionary (`Pricing.Options`), the\nplatform-wide standard. An option's cost can be:\n\n| Kind | What it is | On the storefront view |\n| --------------------------------------- | ------------------------------------------- | ---------------------- |\n| Virtual currency / items / event tokens | ordinary `ResourceConsume` | — |\n| Crypto | a crypto currency entry | — |\n| Real money | a store product entry; needs a receipt | `IsStorePaid: true` |\n| Rewarded video | an ad-credit entry, spent like any resource | `IsAdPaid: true` |\n| **Free** | **no cost at all** | `IsFree: true` |\n\n⚠ **An unset cost means FREE, and that is a legal product** — not an unfilled\nprice. A free offer must be limited on some axis (`TotalCap`, `DailyCap`,\n`CooldownSeconds`, or the slot's `PerInstanceCap`); without a limit the server\nrefuses the purchase, because it would be a button that grants itself forever.\n\n⚠ Ad-paid offers spend a **credit the player already earned** by watching a\nvideo (the Advertising module grants it server-side). Read the balance from\n`storefront.AdCreditBalance` to decide whether a card shows \"Watch\" or \"Buy\".\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst store = client.store; // the StoreService\n```\n\nEvery store method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\nor `{ ok: false, reason, error }`. Always branch on `result.ok`.\n\n| Method | Purpose | `data` on success |\n| ------------------------------------- | ---------------------------------------- | ----------------------- |\n| `getStorefront()` | **The shop as this player sees it now.** | `GetStorefrontResponse` |\n| `getDefinitions()` | The raw catalogue (config). | `StoreDefinitions` |\n| `getUserState()` | Purchase counters (state). | `UserStoreState` |\n| `purchase(offerID, count?, options?)` | Buy `count` (default 1) of one offer. | `StorePurchaseResponse` |\n| `purchaseBatch(purchases)` | Buy several offers in one atomic call. | `PurchaseBatchResponse` |\n\n`options` = `{ selectedOptionID?, payment?, slot? }`.\n\n`purchase` clamps `count` server-side to **1–100**. `purchaseBatch` takes\n`StorePurchaseRef[]`, deduped by `OfferID`, clamped to **50 refs per call**.\n\n## Drawing a shop screen\n\n```ts\nconst res = await client.store.getStorefront();\nif (!res.ok) return showError(res.error);\nconst front = res.data;\n\nfor (const store of front.Stores ?? []) {\n for (const section of store.Sections ?? []) {\n for (const slot of section.Slots ?? []) {\n // ⚠ An EMPTY slot arrives with Offer === null and must still be drawn as\n // an empty frame. Skipping it makes the shelf jump around whenever a\n // window closes or a gate filters the pool — which is exactly why the\n // server sends the slot rather than omitting it.\n if (!slot.Offer) {\n drawEmptyCard(slot.RefreshesAtUtc); // \"opens in ...\"\n continue;\n }\n\n const offer = slot.Offer;\n const state = offer.State;\n\n drawCard({\n title: offer.Identity?.DisplayName, // localization key OR a literal\n ribbons: offer.Identity?.Tags, // static badges: best_value, x2 ...\n // Server-computed badges — do not recompute these:\n firstPurchase: state?.IsFirstPurchaseAvailable,\n soldOut: state?.SoldOut,\n readyAt: state?.AvailableAtUtc, // free-slot cooldown\n // Rotation timer: \"Refreshes in 15h 40m\"\n refreshesAt: slot.RefreshesAtUtc,\n remainingThisWindow: slot.RemainingThisRotation, // null = unlimited\n prices: offer.PriceOptions, // already filtered to this platform\n });\n }\n }\n}\n```\n\n⚠ **Count timers down from `front.ServerTimeUtc`, not from the device clock.**\nThat is what it is for; a player with a skewed clock otherwise sees a shop that\nrefreshes at the wrong moment or claims to have already refreshed.\n\n⚠ **`null` and `0` mean different things in every remaining-count field.**\n`null` = \"no limit on this axis\"; `0` = \"spent, sold out\". Treating `null` as\nzero grays out every unlimited product on the shelf.\n\n`client.store.nextRefreshAtUtc()` returns the nearest refresh across all\nvisible slots — use it to schedule one reload instead of a polling loop.\n\n## Purchasing\n\n```ts\nconst res = await client.store.purchase(offer.OfferID, 1, {\n selectedOptionID: option.OptionID,\n // Pass the slot the player tapped — the storefront gives you all three ids.\n slot: {\n storeID: store.StoreID,\n sectionID: section.SectionID,\n slotID: slot.SlotID,\n },\n});\nif (!res.ok) return showError(res.error);\n```\n\nAn option paid in a store needs the receipt as well:\n\n```ts\nawait client.store.purchase(\"gems_l\", 1, {\n selectedOptionID: option.OptionID,\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n⚠ **The slot address is optional as a whole** (all three or none): without it\nthe server finds the slot itself. The roll is re-checked either way, so a\nproduct that rolled for the player nowhere cannot be bought with or without an\naddress. Pass it anyway when you have it — for a pooled product it removes the\nambiguity of which slot's per-window cap applies.\n\n### ⚠ `OFFER_NOT_IN_ROTATION` means \"reload the shop\", not \"show an error\"\n\nThe rotation roll is recomputed, never stored, so editing a slot's pool\nreshuffles the shelf for everyone who has it open. A player who taps a card\nthat is no longer in their roll gets exactly this error:\n\n```ts\nimport { OFFER_NOT_IN_ROTATION } from \"@idosgames/core\";\n\nif (!res.ok && res.error === OFFER_NOT_IN_ROTATION) {\n await client.store.getStorefront(); // silently redraw; do not toast\n return;\n}\n```\n\nAlready-completed purchases are never undone by a reshuffle — counters are\naddressed by product and slot, not by the roll.\n\n## Events\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `store:storefrontLoaded` → `GetStorefrontResponse`\n- `store:definitionsLoaded` → `StoreDefinitions`\n- `store:userStateLoaded` → `UserStoreState`\n- `store:offerPurchased` → `StorePurchaseResponse`\n- `store:offersPurchasedBatch` → `PurchaseBatchResponse`\n\nThe coarse `user:storeUpdated` (and `user:anyUpdated`) also fire on any store\ncache write.\n\n## Batch purchase\n\n```ts\nconst res = await client.store.purchaseBatch([\n { OfferID: \"pack1\", Count: 1 },\n { OfferID: \"starter_bundle\", Count: 1 },\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data) {\n if (item.Success) applyOk(item.Id);\n else showItemError(item.Id, item.Error);\n}\n```\n\nBatch results are **partial-aware**: the outer `res.ok` says the call ran, each\nelement's `Success`/`Error` says whether that item applied. Items rejected on\ntheir own merits (unknown id, outside window, gate failed, limit reached, not\nin rotation) are filtered out _before_ the merged charge is built. The\nsurvivors are then charged as **one merged, all-or-nothing transaction**.\n\n⚠ An option paid in a **store** cannot go in a batch — one receipt pays for one\npurchase. Ad credits can: a credit is an ordinary resource and two of them add\nup like two coins.\n\n## Gotchas\n\n- **Guard against double-submit.** Each `purchase()` mints a fresh idempotency\n key, so two calls are two real operations — a double-clicked \"Buy\" charges\n twice. Disable the control while a call is in flight.\n- **The struck-through price is display only.** `CompareAtCost` never reaches\n the charge. `DiscountPercent` is computed by the server and is `null` when it\n cannot be computed (a mixed bundle has no single percentage) — `null` is not\n zero, and printing \"0%\" there is a lie.\n- **Prices and rewards can be adjusted server-side.** Premium tiers apply\n discounts and bonuses automatically; read what was actually charged and\n granted off `res.data.Resources`, not off the displayed base values.\n- **`count` scales cost and rewards linearly, then premium is applied once** —\n it is not N independent purchases.\n- **Only one `Resources` apply per batch call**, but every item's own\n `OfferID`/`Count`/`ServerTimeUtc` is correct and drives its counter patch.\n- **`DailyPurchases` resets on UTC midnight.** Read `DailyResetUtc` from the\n cache after a call rather than computing it.\n- **Tags are for the client; player-dependent badges are not.** Anything whose\n truth depends on the player — first purchase, sold out, a timer — arrives in\n `State`, computed by the server. Do not encode those as tags.\n- **`DisplayName` is a localization key OR a literal.** Resolution is\n `t(x) = own table → fallback table → x itself`, so a title with literal\n names works with no migration. Run these through the localization module.\n- **Render from the cache, handle the error from the result.** Use `reason` to\n decide behaviour (retry on `\"connection\"`, re-auth on `\"unauthorized\"`, toast\n the `error` on `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstorefront field, the rotation modes, the purchase-limit/reset rule matrix, and\nbatch semantics. Read it when building config-driven UI or when an error points\nat a rule you need to understand.\n",
4
+ "content": "---\r\nname: store-system\r\ndescription: >-\r\n Build a store / shop screen in a game on the iDosGames TypeScript SDK\r\n (@idosgames/core) via client.store (StoreService): load the RESOLVED\r\n storefront for the current player (rotating daily shops, sections, slots,\r\n badges, refresh timers, remaining limits), load the offer catalogue, and\r\n purchase one or many offers. Prices can be virtual currency, crypto, a real\r\n money store product, a rewarded-video ad credit, or free. Use this whenever\r\n the user is working in the iDosGames TS SDK or its game templates\r\n (board-game, idle-rpg) and wants a shop screen, a daily rotating shop, gem\r\n packs, free/ad-paid slots, first-purchase badges, purchase-limit UI, or\r\n otherwise touches client.store, StoreService, GetStorefrontResponse,\r\n StoreDefinitions or offer purchasing — even if they don't name the module.\r\n---\r\n\r\n# Store system (iDosGames TS SDK)\r\n\r\nA title sells **offers** (products) placed on **shelves**. The shop is a tree:\r\n\r\n```\r\nStore (storefront)\r\n└── Layout.Sections\r\n └── Slots ← a slot is a PLACE on the shelf\r\n └── the slot decides which offer stands in it\r\n```\r\n\r\n⚠⚠ **A slot is the only address a product can be bought through.** An offer\r\nlives in a flat catalogue (`StoreOffers`) and becomes buyable only by standing\r\nin a slot; a product placed in no slot **cannot be bought at all**. There is no\r\n\"list of stores\" on the product — that field was removed, because it was never\r\na right to buy: the purchase never checked it.\r\n\r\nEverything is **server-authoritative**. The client asks to purchase, the\r\nbackend validates price, windows, gates, limits **and the rotation roll**, then\r\nthe SDK mirrors the confirmed result into a local cache your UI reads.\r\n\r\n## ⚠ Draw `getStorefront()`, not `getDefinitions()`\r\n\r\nThis is the single most important rule of the module.\r\n\r\n`getDefinitions()` returns the **config** — identical for every player.\r\n`getStorefront()` returns the shop **resolved for this player**: rotation\r\nalready rolled, gates applied, badges and refresh timers computed, remaining\r\nlimits filled in. None of that can be derived on the client:\r\n\r\n- **Rotation** is recomputed by the server on every read and stored nowhere. A\r\n client rolling it itself would show two devices two different shops.\r\n- **Counters** (`PurchasedTotal`, remaining caps, cooldown) belong to the\r\n player and are not in the config.\r\n- **Gates** filter storefronts, sections, slots and pool entries per player.\r\n\r\nUse `getDefinitions()` only for catalogue-wide tooling (an admin view, a\r\nsearch). A shop screen never needs it.\r\n\r\n## Prices: six kinds, one shape\r\n\r\nPrice is always a `PriceOptions` dictionary (`Pricing.Options`), the\r\nplatform-wide standard. An option's cost can be:\r\n\r\n| Kind | What it is | On the storefront view |\r\n| --------------------------------------- | ------------------------------------------- | ---------------------- |\r\n| Virtual currency / items / event tokens | ordinary `ResourceConsume` | — |\r\n| Crypto | a crypto currency entry | — |\r\n| Real money | a store product entry; needs a receipt | `IsStorePaid: true` |\r\n| Rewarded video | an ad-credit entry, spent like any resource | `IsAdPaid: true` |\r\n| **Credits** (platform currency) | a `PlatformCredits` entry, `Amount` = price | `IsCreditsPaid`, `CreditsPrice` |\r\n| **Free** | **no cost at all** | `IsFree: true` |\r\n\r\n⚠ **An unset cost means FREE, and that is a legal product** — not an unfilled\r\nprice. A free offer must be limited on some axis (`TotalCap`, `DailyCap`,\r\n`CooldownSeconds`, or the slot's `PerInstanceCap`); without a limit the server\r\nrefuses the purchase, because it would be a button that grants itself forever.\r\n\r\n⚠ Ad-paid offers spend a **credit the player already earned** by watching a\r\nvideo (the Advertising module grants it server-side). Read the balance from\r\n`storefront.AdCreditBalance` to decide whether a card shows \"Watch\" or \"Buy\".\r\n\r\n## Setup\r\n\r\n```ts\r\nimport { createIDosGamesClient } from \"@idosgames/core\";\r\n\r\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\r\nawait client.auth.loginWithDeviceID(); // or any auth.* method\r\n\r\nconst store = client.store; // the StoreService\r\n```\r\n\r\nEvery store method requires an authenticated session. Without one they return\r\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw.\r\n\r\n## Methods\r\n\r\nAll methods return `Promise<OperationResult<T>>`: either `{ ok: true, data }`\r\nor `{ ok: false, reason, error }`. Always branch on `result.ok`.\r\n\r\n| Method | Purpose | `data` on success |\r\n| ------------------------------------- | ---------------------------------------- | ----------------------- |\r\n| `getStorefront()` | **The shop as this player sees it now.** | `GetStorefrontResponse` |\r\n| `getDefinitions()` | The raw catalogue (config). | `StoreDefinitions` |\r\n| `getUserState()` | Purchase counters (state). | `UserStoreState` |\r\n| `purchase(offerID, count?, options?)` | Buy `count` (default 1) of one offer. | `StorePurchaseResponse` |\r\n| `purchaseBatch(purchases)` | Buy several offers in one atomic call. | `PurchaseBatchResponse` |\r\n\r\n`options` = `{ selectedOptionID?, payment?, slot? }`.\r\n\r\n`purchase` clamps `count` server-side to **1–100**. `purchaseBatch` takes\r\n`StorePurchaseRef[]`, deduped by `OfferID`, clamped to **50 refs per call**.\r\n\r\n## Drawing a shop screen\r\n\r\n```ts\r\nconst res = await client.store.getStorefront();\r\nif (!res.ok) return showError(res.error);\r\nconst front = res.data;\r\n\r\nfor (const store of front.Stores ?? []) {\r\n for (const section of store.Sections ?? []) {\r\n for (const slot of section.Slots ?? []) {\r\n // ⚠ An EMPTY slot arrives with Offer === null and must still be drawn as\r\n // an empty frame. Skipping it makes the shelf jump around whenever a\r\n // window closes or a gate filters the pool — which is exactly why the\r\n // server sends the slot rather than omitting it.\r\n if (!slot.Offer) {\r\n drawEmptyCard(slot.RefreshesAtUtc); // \"opens in ...\"\r\n continue;\r\n }\r\n\r\n const offer = slot.Offer;\r\n const state = offer.State;\r\n\r\n drawCard({\r\n title: offer.Identity?.DisplayName, // localization key OR a literal\r\n ribbons: offer.Identity?.Tags, // static badges: best_value, x2 ...\r\n // Server-computed badges — do not recompute these:\r\n firstPurchase: state?.IsFirstPurchaseAvailable,\r\n soldOut: state?.SoldOut,\r\n readyAt: state?.AvailableAtUtc, // free-slot cooldown\r\n // Rotation timer: \"Refreshes in 15h 40m\"\r\n refreshesAt: slot.RefreshesAtUtc,\r\n remainingThisWindow: slot.RemainingThisRotation, // null = unlimited\r\n prices: offer.PriceOptions, // already filtered to this platform\r\n });\r\n }\r\n }\r\n}\r\n```\r\n\r\n⚠ **Count timers down from `front.ServerTimeUtc`, not from the device clock.**\r\nThat is what it is for; a player with a skewed clock otherwise sees a shop that\r\nrefreshes at the wrong moment or claims to have already refreshed.\r\n\r\n⚠ **`null` and `0` mean different things in every remaining-count field.**\r\n`null` = \"no limit on this axis\"; `0` = \"spent, sold out\". Treating `null` as\r\nzero grays out every unlimited product on the shelf.\r\n\r\n`client.store.nextRefreshAtUtc()` returns the nearest refresh across all\r\nvisible slots — use it to schedule one reload instead of a polling loop.\r\n\r\n## Purchasing\r\n\r\n```ts\r\nconst res = await client.store.purchase(offer.OfferID, 1, {\r\n selectedOptionID: option.OptionID,\r\n // Pass the slot the player tapped — the storefront gives you all three ids.\r\n slot: {\r\n storeID: store.StoreID,\r\n sectionID: section.SectionID,\r\n slotID: slot.SlotID,\r\n },\r\n});\r\nif (!res.ok) return showError(res.error);\r\n```\r\n\r\nAn option paid in a store needs the receipt as well:\r\n\r\n```ts\r\nawait client.store.purchase(\"gems_l\", 1, {\r\n selectedOptionID: option.OptionID,\r\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\r\n});\r\n```\r\n\r\n⚠ **The slot address is optional as a whole** (all three or none): without it\r\nthe server finds the slot itself. The roll is re-checked either way, so a\r\nproduct that rolled for the player nowhere cannot be bought with or without an\r\naddress. Pass it anyway when you have it — for a pooled product it removes the\r\nambiguity of which slot's per-window cap applies.\r\n\r\n### Credits — the platform currency (`IsCreditsPaid`)\r\n\r\n⚠ **Players see this currency as \"Aspis\"** (owner's decision 26.10.2026) — write \"Aspis\" in any player-facing text\r\n(price \"100 Aspis\", \"Not enough Aspis\"). \"Credits\" is only the API name (`PlatformCredits`, `purchaseWithCredits`,\r\n`CreditsError`, `IsCreditsPaid`); do not name a game's own currency \"Coins\" next to it either — players confuse them.\r\n\r\nCredits live on the player's **iDos Games account**, not in the game; the game's author gets 70% of the price, the\r\nplatform 30%. ⚠ The game **cannot charge Credits by itself**: the first call only creates a purchase request, the\r\nplayer confirms it on idosgames.com with their own account, and only then the purchase completes. On idosgames.com's\r\nown game page the SITE shows its confirmation window right over the game — `purchaseWithCredits` handles that by itself\r\nand `onConfirmation` is not called; on the game's own address or `/embed` it falls back to the link. Use the one call\r\nthat does the whole flow:\r\n\r\n```ts\r\nimport { CreditsError } from \"@idosgames/core\";\r\n\r\nconst res = await client.store.purchaseWithCredits(offer.OfferID, 1, {\r\n selectedOptionID: option.OptionID,\r\n slot: { storeID, sectionID, slotID },\r\n // Show a LINK/button to view.Url (target=\"_blank\") — not window.open after an await: browsers block that.\r\n onConfirmation: (view) => showConfirmLink(view.Url),\r\n signal: abort.signal, // stop waiting when the popup closes\r\n});\r\nif (!res.ok && res.error === CreditsError.NotEnough) {\r\n const b = await client.store.getCreditsBalance(); // b.data.TopUpUrl — where to top up\r\n}\r\n```\r\n\r\n- Price the option in Credits in LiveOps → Store (resource picker → **Credits**). Only the Store accepts Credits; one Credits\r\n entry per option, never together with a store product, never as a reward.\r\n- Refusals (`CreditsError`): `CREDITS_LOGIN_REQUIRED` (guest — sign in with iDos Games), `NOT_ENOUGH_CREDITS`,\r\n `CREDITS_CONFIRMATION_DECLINED` / `_EXPIRED`, `CREDITS_BATCH_FORBIDDEN` (no Credits in `purchaseBatch`).\r\n- `getCreditsBalance()` — Credits the player can spend in games (bought + earned; bonus Credits are for platform services).\r\n- On the DEV copy of the title a Credits purchase is a free test: no request, no Credits moved.\r\n\r\n### ⚠ `OFFER_NOT_IN_ROTATION` means \"reload the shop\", not \"show an error\"\r\n\r\nThe rotation roll is recomputed, never stored, so editing a slot's pool\r\nreshuffles the shelf for everyone who has it open. A player who taps a card\r\nthat is no longer in their roll gets exactly this error:\r\n\r\n```ts\r\nimport { OFFER_NOT_IN_ROTATION } from \"@idosgames/core\";\r\n\r\nif (!res.ok && res.error === OFFER_NOT_IN_ROTATION) {\r\n await client.store.getStorefront(); // silently redraw; do not toast\r\n return;\r\n}\r\n```\r\n\r\nAlready-completed purchases are never undone by a reshuffle — counters are\r\naddressed by product and slot, not by the roll.\r\n\r\n## Events\r\n\r\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\r\n\r\n- `store:storefrontLoaded` → `GetStorefrontResponse`\r\n- `store:definitionsLoaded` → `StoreDefinitions`\r\n- `store:userStateLoaded` → `UserStoreState`\r\n- `store:offerPurchased` → `StorePurchaseResponse`\r\n- `store:offersPurchasedBatch` → `PurchaseBatchResponse`\r\n\r\nThe coarse `user:storeUpdated` (and `user:anyUpdated`) also fire on any store\r\ncache write.\r\n\r\n## Batch purchase\r\n\r\n```ts\r\nconst res = await client.store.purchaseBatch([\r\n { OfferID: \"pack1\", Count: 1 },\r\n { OfferID: \"starter_bundle\", Count: 1 },\r\n]);\r\nif (!res.ok) return showError(res.error);\r\nfor (const item of res.data) {\r\n if (item.Success) applyOk(item.Id);\r\n else showItemError(item.Id, item.Error);\r\n}\r\n```\r\n\r\nBatch results are **partial-aware**: the outer `res.ok` says the call ran, each\r\nelement's `Success`/`Error` says whether that item applied. Items rejected on\r\ntheir own merits (unknown id, outside window, gate failed, limit reached, not\r\nin rotation) are filtered out _before_ the merged charge is built. The\r\nsurvivors are then charged as **one merged, all-or-nothing transaction**.\r\n\r\n⚠ An option paid in a **store** cannot go in a batch — one receipt pays for one\r\npurchase. Ad credits can: a credit is an ordinary resource and two of them add\r\nup like two coins.\r\n\r\n## Gotchas\r\n\r\n- **Guard against double-submit.** Each `purchase()` mints a fresh idempotency\r\n key, so two calls are two real operations — a double-clicked \"Buy\" charges\r\n twice. Disable the control while a call is in flight.\r\n- **The struck-through price is display only.** `CompareAtCost` never reaches\r\n the charge. `DiscountPercent` is computed by the server and is `null` when it\r\n cannot be computed (a mixed bundle has no single percentage) — `null` is not\r\n zero, and printing \"0%\" there is a lie.\r\n- **Prices and rewards can be adjusted server-side.** Premium tiers apply\r\n discounts and bonuses automatically; read what was actually charged and\r\n granted off `res.data.Resources`, not off the displayed base values.\r\n- **`count` scales cost and rewards linearly, then premium is applied once** —\r\n it is not N independent purchases.\r\n- **Only one `Resources` apply per batch call**, but every item's own\r\n `OfferID`/`Count`/`ServerTimeUtc` is correct and drives its counter patch.\r\n- **`DailyPurchases` resets on UTC midnight.** Read `DailyResetUtc` from the\r\n cache after a call rather than computing it.\r\n- **Tags are for the client; player-dependent badges are not.** Anything whose\r\n truth depends on the player — first purchase, sold out, a timer — arrives in\r\n `State`, computed by the server. Do not encode those as tags.\r\n- **`DisplayName` is a localization key OR a literal.** Resolution is\r\n `t(x) = own table → fallback table → x itself`, so a title with literal\r\n names works with no migration. Run these through the localization module.\r\n- **Render from the cache, handle the error from the result.** Use `reason` to\r\n decide behaviour (retry on `\"connection\"`, re-auth on `\"unauthorized\"`, toast\r\n the `error` on `\"server\"`).\r\n\r\n## Full reference\r\n\r\n[references/data-model.md](references/data-model.md) — every config and\r\nstorefront field, the rotation modes, the purchase-limit/reset rule matrix, and\r\nbatch semantics. Read it when building config-driven UI or when an error points\r\nat a rule you need to understand.\r\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",