@nekosuneprojects/gaming-infos 2.1.0 → 2.2.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.
File without changes
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "Tower of Fantasy",
3
+ "title": "Shared open-world action RPG by Hotta Studio.",
4
+ "engine": "Unreal Engine 4",
5
+ "Platform": "Windows, PlayStation, iOS, Android",
6
+ "Release date": "December 16, 2021",
7
+ "Genre": "Action RPG, Open World, Online, Gacha",
8
+ "developers": "Hotta Studio",
9
+ "publisher": "Perfect World / Level Infinite",
10
+ "aliases": "ToF, Tower of Fantasy",
11
+ "image": "https://www.toweroffantasy-global.com/favicon.ico",
12
+ "description": "Online action RPG featuring Simulacra, each associated with a signature weapon and combat role.",
13
+ "url": "https://www.toweroffantasy-global.com/"
14
+ }
File without changes
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "Warframe",
3
+ "title": "Cooperative science-fiction action game by Digital Extremes.",
4
+ "engine": "Evolution",
5
+ "Platform": "Windows, PlayStation, Xbox, Nintendo Switch, iOS, Android",
6
+ "Release date": "March 25, 2013",
7
+ "Genre": "Action RPG, Third-person shooter, Online co-op",
8
+ "developers": "Digital Extremes",
9
+ "publisher": "Digital Extremes",
10
+ "aliases": "WF, Warframe",
11
+ "image": "https://www.warframe.com/favicon.ico",
12
+ "description": "Online action game built around collectible Warframes, weapons, companions and the Origin System.",
13
+ "url": "https://www.warframe.com/"
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "Wuthering Waves",
3
+ "title": "Open-world action RPG by Kuro Games.",
4
+ "engine": "Unreal Engine 4",
5
+ "Platform": "Windows, PlayStation 5, iOS, Android, macOS",
6
+ "Release date": "May 22, 2024",
7
+ "Genre": "Action RPG, Open World, Gacha",
8
+ "developers": "Kuro Games",
9
+ "publisher": "Kuro Games",
10
+ "aliases": "WuWa, Wuthering Waves",
11
+ "image": "https://wutheringwaves.kurogames.com/favicon.ico",
12
+ "description": "Open-world action RPG featuring playable Resonators with distinct attributes and weapons.",
13
+ "url": "https://wutheringwaves.kurogames.com/"
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "Zenless Zone Zero",
3
+ "title": "Urban fantasy action RPG by HoYoverse.",
4
+ "engine": "Unity",
5
+ "Platform": "Windows, PlayStation 5, Xbox Series X/S, iOS, Android",
6
+ "Release date": "July 4, 2024",
7
+ "Genre": "Action RPG, Urban Fantasy, Gacha",
8
+ "developers": "miHoYo",
9
+ "publisher": "HoYoverse",
10
+ "aliases": "ZZZ, Zenless Zone Zero",
11
+ "image": "https://zenless.hoyoverse.com/favicon.ico",
12
+ "description": "Action RPG featuring playable Agents organized by faction, attribute and specialty.",
13
+ "url": "https://zenless.hoyoverse.com/"
14
+ }
package/gamecodes.js ADDED
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const DATA_DIR = path.join(__dirname, 'data', 'game-codes');
7
+ const DEFAULT_API_BASE = 'https://api.nekosunevr.co.uk';
8
+ const DEFAULT_API_PATH = '/v5/games/api/codes';
9
+ const DEFAULT_TIMEOUT_MS = 4000;
10
+
11
+ function apiBase() {
12
+ return String(process.env.GAMING_INFOS_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
13
+ }
14
+
15
+ function apiPath() {
16
+ const raw = String(process.env.GAME_CODES_API_PATH || DEFAULT_API_PATH).trim();
17
+ return `/${raw.replace(/^\/+|\/+$/g, '')}`;
18
+ }
19
+
20
+ function apiDisabled() {
21
+ const raw = String(process.env.GAMING_INFOS_DISABLE_API || '').toLowerCase();
22
+ return raw === '1' || raw === 'true';
23
+ }
24
+
25
+ function timeoutMs() {
26
+ const raw = Number(process.env.GAMING_INFOS_TIMEOUT_MS);
27
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
28
+ }
29
+
30
+ async function apiGet(suffix = '', query = {}) {
31
+ if (apiDisabled() || typeof fetch !== 'function') return null;
32
+
33
+ const url = new URL(`${apiBase()}${apiPath()}${suffix}`);
34
+ for (const [key, value] of Object.entries(query)) {
35
+ if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value));
36
+ }
37
+
38
+ const controller = new AbortController();
39
+ const timer = setTimeout(() => controller.abort(), timeoutMs());
40
+ try {
41
+ const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } });
42
+ if (!response.ok) return null;
43
+ const body = await response.json();
44
+ if (!body || body.success === false) return null;
45
+ return body;
46
+ } catch (_) {
47
+ return null;
48
+ } finally {
49
+ clearTimeout(timer);
50
+ }
51
+ }
52
+
53
+ async function readJson(filePath) {
54
+ return JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
55
+ }
56
+
57
+ function safeFallbackPath(fileName) {
58
+ const filePath = path.resolve(DATA_DIR, fileName);
59
+ const root = `${path.resolve(DATA_DIR)}${path.sep}`;
60
+ return filePath.startsWith(root) ? filePath : null;
61
+ }
62
+
63
+ async function readFallback(fileName) {
64
+ const filePath = safeFallbackPath(fileName);
65
+ if (!filePath || !fs.existsSync(filePath)) return null;
66
+ try {
67
+ return await readJson(filePath);
68
+ } catch (_) {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function normalizeGame(game) {
74
+ const slug = String(game || '').trim().toLowerCase();
75
+ return /^[a-z0-9][a-z0-9-]*$/.test(slug) ? slug : null;
76
+ }
77
+
78
+ function normalizeCode(code) {
79
+ return String(code || '').trim().toLowerCase();
80
+ }
81
+
82
+ async function resolveCachedGame(game) {
83
+ const slug = normalizeGame(game);
84
+ if (!slug) return null;
85
+ if (await readFallback(path.join('games', `${slug}.json`))) return slug;
86
+
87
+ const cachedDirectory = await readFallback('directory.json');
88
+ const aliasTarget = cachedDirectory?.aliases?.[slug];
89
+ const canonical = normalizeGame(aliasTarget);
90
+ if (canonical && await readFallback(path.join('games', `${canonical}.json`))) return canonical;
91
+ return slug;
92
+ }
93
+
94
+ function filterCachedList(data, options = {}) {
95
+ if (!data || typeof data !== 'object') return data;
96
+ const includeUnknown = Boolean(options.includeUnknown);
97
+ const category = options.category ? String(options.category).toLowerCase() : null;
98
+ const claimType = options.claimType ? String(options.claimType).toLowerCase() : null;
99
+
100
+ const matches = (entry) => {
101
+ if (!entry || typeof entry !== 'object') return false;
102
+ if (category && String(entry.category || '').toLowerCase() !== category) return false;
103
+ if (claimType && String(entry.claimType || '').toLowerCase() !== claimType) return false;
104
+ return true;
105
+ };
106
+
107
+ const result = {
108
+ Active: Array.isArray(data.Active) ? data.Active.filter(matches) : [],
109
+ Expired: Array.isArray(data.Expired) ? data.Expired.filter(matches) : []
110
+ };
111
+ if (includeUnknown) result.Unknown = Array.isArray(data.Unknown) ? data.Unknown.filter(matches) : [];
112
+ return result;
113
+ }
114
+
115
+ async function directory() {
116
+ const live = await apiGet('');
117
+ if (live) return live;
118
+ const cached = await readFallback('directory.json');
119
+ return cached || { success: false, error: 'Game code directory is unavailable and no fallback cache exists.' };
120
+ }
121
+
122
+ async function status() {
123
+ const live = await apiGet('/status');
124
+ if (live) return live.data !== undefined ? live.data : live;
125
+ const cached = await readFallback('status.json');
126
+ return cached || { error: 'Game code status is unavailable and no fallback cache exists.' };
127
+ }
128
+
129
+ async function gameCodes(game, options = {}) {
130
+ if (!game) return { error: 'game is required' };
131
+ const requested = normalizeGame(game);
132
+ if (!requested) return { error: 'invalid game slug' };
133
+ const query = {
134
+ timezone: options.timezone,
135
+ category: options.category,
136
+ claimType: options.claimType,
137
+ includeUnknown: options.includeUnknown ? 'true' : undefined
138
+ };
139
+ const live = await apiGet(`/${encodeURIComponent(requested)}`, query);
140
+ if (live) return live;
141
+
142
+ const cachedGame = await resolveCachedGame(requested);
143
+ const cached = cachedGame ? await readFallback(path.join('games', `${cachedGame}.json`)) : null;
144
+ if (!cached) return { error: `No cached game codes found for "${game}".` };
145
+ return filterCachedList(cached, options);
146
+ }
147
+
148
+ async function gameCode(game, code, options = {}) {
149
+ if (!game || !code) return { error: 'game and code are both required' };
150
+ const requested = normalizeGame(game);
151
+ if (!requested) return { error: 'invalid game slug' };
152
+ const codeValue = String(code).trim();
153
+ const live = await apiGet(`/${encodeURIComponent(requested)}/${encodeURIComponent(codeValue)}`, { timezone: options.timezone });
154
+ if (live) return live.data !== undefined ? live.data : live;
155
+
156
+ const cachedGame = await resolveCachedGame(requested);
157
+ const cached = cachedGame ? await readFallback(path.join('games', `${cachedGame}.json`)) : null;
158
+ if (!cached) return { error: `No cached game codes found for "${game}".` };
159
+ const wanted = normalizeCode(codeValue);
160
+ for (const bucket of ['Active', 'Expired', 'Unknown']) {
161
+ for (const entry of Array.isArray(cached[bucket]) ? cached[bucket] : []) {
162
+ if (normalizeCode(entry?.code) === wanted) return entry;
163
+ }
164
+ }
165
+ return { error: `Code "${code}" not found for game "${game}".` };
166
+ }
167
+
168
+ async function cacheInfo() {
169
+ return readFallback('.cache-manifest.json');
170
+ }
171
+
172
+ module.exports = {
173
+ Codes: () => directory(),
174
+ CodesStatus: () => status(),
175
+ GameCodes: (game, options) => gameCodes(game, options),
176
+ GameCode: (game, code, options) => gameCode(game, code, options),
177
+ CodeCacheInfo: () => cacheInfo()
178
+ };
package/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const gameCodes = require('./gamecodes');
3
4
 
4
5
  const DATA_DIR = path.join(__dirname, 'data');
5
6
  const DEFAULT_API_BASE = 'https://api.nekosunevr.co.uk';
@@ -23,9 +24,15 @@ function timeoutMs() {
23
24
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
24
25
  }
25
26
 
27
+ function withLocalSlug(filePath, data) {
28
+ if (!data || Array.isArray(data) || typeof data !== 'object' || data.slug) return data;
29
+ return { ...data, slug: path.basename(filePath, '.json').toLowerCase() };
30
+ }
31
+
26
32
  // Live lookups go through the NekoSuneVR V5 API first so data can be updated
27
33
  // in real time without republishing this package. The bundled JSON under
28
- // data/ is only the offline fallback for when the API can't be reached.
34
+ // data/ is the offline fallback and is mirrored from APINODE by the scheduled
35
+ // sync workflow.
29
36
  async function fetchFromApi(game, type, name) {
30
37
  if (apiDisabled() || typeof fetch !== 'function') return null;
31
38
 
@@ -53,7 +60,7 @@ async function loadLocal(game, type, name) {
53
60
  }
54
61
 
55
62
  const raw = await fs.promises.readFile(filePath, 'utf8');
56
- return JSON.parse(raw);
63
+ return withLocalSlug(filePath, JSON.parse(raw));
57
64
  }
58
65
 
59
66
  async function fetchListFromApi(game, type) {
@@ -81,12 +88,12 @@ async function listLocal(game, type) {
81
88
  const dir = path.join(DATA_DIR, game.toLowerCase(), type);
82
89
  if (!fs.existsSync(dir)) return [];
83
90
 
84
- const files = await fs.promises.readdir(dir);
91
+ const files = (await fs.promises.readdir(dir)).filter((file) => file.endsWith('.json')).sort((a, b) => a.localeCompare(b));
85
92
  const items = [];
86
93
  for (const file of files) {
87
- if (!file.endsWith('.json')) continue;
88
- const raw = await fs.promises.readFile(path.join(dir, file), 'utf8');
89
- items.push(JSON.parse(raw));
94
+ const filePath = path.join(dir, file);
95
+ const raw = await fs.promises.readFile(filePath, 'utf8');
96
+ items.push(withLocalSlug(filePath, JSON.parse(raw)));
90
97
  }
91
98
  return items;
92
99
  }
@@ -131,7 +138,7 @@ async function listGamesLocal() {
131
138
  const entries = await fs.promises.readdir(DATA_DIR, { withFileTypes: true });
132
139
  const games = [];
133
140
  for (const entry of entries) {
134
- if (!entry.isDirectory()) continue;
141
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'game-codes') continue;
135
142
  const metaPath = path.join(DATA_DIR, entry.name, 'meta.json');
136
143
  if (!fs.existsSync(metaPath)) continue;
137
144
  const raw = await fs.promises.readFile(metaPath, 'utf8');
@@ -185,23 +192,33 @@ async function loadGameMeta(game) {
185
192
  }
186
193
  }
187
194
 
195
+ async function cacheInfo() {
196
+ try {
197
+ const filePath = path.join(DATA_DIR, '.cache-manifest.json');
198
+ if (!fs.existsSync(filePath)) return null;
199
+ return JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
200
+ } catch (err) {
201
+ return { error: err.message };
202
+ }
203
+ }
204
+
188
205
  module.exports = {
189
- // All supported games, e.g. Games() -> [data/vrchat/meta.json, data/genshinimpact/meta.json, ...]
190
206
  Games: () => games(),
191
-
192
- // Game-level info, e.g. Game('vrchat') -> data/vrchat/meta.json
193
207
  Game: (game) => loadGameMeta(game),
194
-
195
- // Entity lookups, scoped per game: Worlds('vrchat', 'theblackcat')
196
208
  Worlds: (game, name) => load(game, 'worlds', name),
197
209
  Groups: (game, name) => load(game, 'groups', name),
198
210
  Players: (game, name) => load(game, 'players', name),
211
+ Avatars: (game, name) => load(game, 'avatars', name),
199
212
  Characters: (game, name) => load(game, 'characters', name),
200
-
201
- // Generic escape hatch for entity types not covered above
202
- // (new games may need types other than worlds/groups/players/characters)
203
213
  Get: (game, type, name) => load(game, type, name),
204
-
205
- // Full listing of every entry of a given type for a game, e.g. List('genshinimpact', 'characters')
206
214
  List: (game, type) => list(game, type),
215
+ CacheInfo: () => cacheInfo(),
216
+
217
+ // Game redeem/creator code API helpers. These also use live APINODE first and
218
+ // automatically fall back to the daily mirrored data/game-codes snapshot.
219
+ Codes: gameCodes.Codes,
220
+ CodesStatus: gameCodes.CodesStatus,
221
+ GameCodes: gameCodes.GameCodes,
222
+ GameCode: gameCodes.GameCode,
223
+ CodeCacheInfo: gameCodes.CodeCacheInfo
207
224
  };
package/package.json CHANGED
@@ -1,12 +1,20 @@
1
1
  {
2
2
  "name": "@nekosuneprojects/gaming-infos",
3
- "version": "2.1.0",
4
- "description": "Multi-game data loader (VRChat, Genshin Impact, and more to come) for worlds, groups, players, and characters. Fetches live from the NekoSuneVR V5 API with local JSON fallback.",
3
+ "version": "2.2.0",
4
+ "description": "Multi-game data loader for VRChat, character datasets, and game redeem/creator codes. Uses the NekoSuneVR V5 API first with automatically mirrored local JSON fallbacks.",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "engines": {
8
8
  "node": ">=18"
9
9
  },
10
+ "scripts": {
11
+ "sync:cache": "node scripts/sync-all-cache.js",
12
+ "sync:gaming-infos-cache": "node scripts/sync-gaming-info-cache.js",
13
+ "sync:game-codes-cache": "node scripts/sync-game-code-cache.js",
14
+ "test:cache-sync": "node scripts/cache-sync-smoke.js",
15
+ "test:game-code-cache": "node scripts/game-code-cache-smoke.js",
16
+ "test:all-cache": "npm run test:cache-sync && npm run test:game-code-cache"
17
+ },
10
18
  "repository": {
11
19
  "type": "git",
12
20
  "url": "https://github.com/NekoSuneProjects/gaming-infos.git"
@@ -18,11 +26,21 @@
18
26
  "keywords": [
19
27
  "gaming",
20
28
  "infos",
29
+ "game-codes",
30
+ "redeem-codes",
31
+ "creator-codes",
21
32
  "vrchat",
22
33
  "genshin-impact",
34
+ "wuthering-waves",
35
+ "warframe",
36
+ "fortnite",
37
+ "zenless-zone-zero",
38
+ "tower-of-fantasy",
39
+ "arknights-endfield",
23
40
  "worlds",
24
41
  "groups",
25
42
  "players",
43
+ "avatars",
26
44
  "characters"
27
45
  ],
28
46
  "author": "NekoSuneVR",
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+ const fs = require('fs');
5
+ const http = require('http');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { syncApiCache } = require('./sync-api-cache');
9
+
10
+ function json(res, status, body) {
11
+ const payload = JSON.stringify(body);
12
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) });
13
+ res.end(payload);
14
+ }
15
+
16
+ async function main() {
17
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'gaming-infos-cache-test-'));
18
+ const dataDir = path.join(root, 'data');
19
+ let failCharacters = false;
20
+ let characters = [
21
+ { slug: 'alpha', name: 'Alpha', value: 1 },
22
+ { slug: 'beta', name: 'Beta', value: 2 }
23
+ ];
24
+
25
+ const server = http.createServer((req, res) => {
26
+ if (req.url === '/v5/games/api/gaming-infos') {
27
+ return json(res, 200, { success: true, data: [{ name: 'Test Game' }] });
28
+ }
29
+ if (req.url === '/v5/games/api/gaming-infos/testgame/meta/testgame') {
30
+ return json(res, 200, { success: true, data: { name: 'Test Game', description: 'fixture' } });
31
+ }
32
+ if (req.url === '/v5/games/api/gaming-infos/testgame/characters') {
33
+ if (failCharacters) return json(res, 503, { success: false, error: 'fixture outage' });
34
+ return json(res, 200, { success: true, data: characters });
35
+ }
36
+ return json(res, 404, { success: false, error: 'not found' });
37
+ });
38
+
39
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
40
+ const address = server.address();
41
+ const apiBase = `http://127.0.0.1:${address.port}`;
42
+ const options = {
43
+ apiBase,
44
+ apiPath: '/v5/games/api/gaming-infos',
45
+ dataDir,
46
+ manifest: { testgame: ['characters'] },
47
+ retries: 0,
48
+ timeoutMs: 3000
49
+ };
50
+
51
+ try {
52
+ const first = await syncApiCache(options);
53
+ assert.strictEqual(first.changed, true);
54
+ assert.ok(fs.existsSync(path.join(dataDir, 'testgame', 'characters', 'alpha.json')));
55
+ assert.ok(fs.existsSync(path.join(dataDir, 'testgame', 'characters', 'beta.json')));
56
+
57
+ characters = [
58
+ { slug: 'alpha', name: 'Alpha', value: 99 },
59
+ { slug: 'gamma', name: 'Gamma', value: 3 }
60
+ ];
61
+ const second = await syncApiCache(options);
62
+ assert.strictEqual(second.changed, true);
63
+ assert.strictEqual(second.stats.added, 1);
64
+ assert.strictEqual(second.stats.removed, 1);
65
+ assert.ok(second.stats.changed >= 1);
66
+ assert.strictEqual(JSON.parse(await fs.promises.readFile(path.join(dataDir, 'testgame', 'characters', 'alpha.json'), 'utf8')).value, 99);
67
+ assert.ok(fs.existsSync(path.join(dataDir, 'testgame', 'characters', 'gamma.json')));
68
+ assert.ok(!fs.existsSync(path.join(dataDir, 'testgame', 'characters', 'beta.json')));
69
+
70
+ const beforeOutage = await fs.promises.readFile(path.join(dataDir, 'testgame', 'characters', 'alpha.json'), 'utf8');
71
+ failCharacters = true;
72
+ let failed = false;
73
+ try {
74
+ await syncApiCache(options);
75
+ } catch (_) {
76
+ failed = true;
77
+ }
78
+ assert.strictEqual(failed, true, 'partial API outage must fail the sync');
79
+ assert.strictEqual(await fs.promises.readFile(path.join(dataDir, 'testgame', 'characters', 'alpha.json'), 'utf8'), beforeOutage);
80
+ assert.ok(fs.existsSync(path.join(dataDir, 'testgame', 'characters', 'gamma.json')), 'last good snapshot must survive an outage');
81
+
82
+ console.log('gaming-infos cache sync smoke test passed');
83
+ } finally {
84
+ await new Promise((resolve) => server.close(resolve));
85
+ await fs.promises.rm(root, { recursive: true, force: true });
86
+ }
87
+ }
88
+
89
+ main().catch((error) => {
90
+ console.error(error);
91
+ process.exitCode = 1;
92
+ });
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { syncGameCodeCache } = require('./sync-game-code-cache');
8
+
9
+ function response(body, status = 200) {
10
+ return {
11
+ ok: status >= 200 && status < 300,
12
+ status,
13
+ async json() { return body; }
14
+ };
15
+ }
16
+
17
+ async function main() {
18
+ const temp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'game-code-cache-'));
19
+ const dataDir = path.join(temp, 'game-codes');
20
+ let phase = 1;
21
+
22
+ const directory = () => ({
23
+ success: true,
24
+ games: [
25
+ { game: 'alpha', name: 'Alpha', counts: { total: phase === 1 ? 2 : 2 } },
26
+ { game: 'beta', name: 'Beta', counts: { total: 1 } }
27
+ ],
28
+ aliases: { a: 'alpha', b: 'beta' }
29
+ });
30
+
31
+ const datasets = () => ({
32
+ alpha: phase === 1
33
+ ? { Active: [{ code: 'KEEP', status: 'active' }, { code: 'REMOVE', status: 'active' }], Expired: [], Unknown: [] }
34
+ : { Active: [{ code: 'KEEP', status: 'active', notes: 'changed' }, { code: 'ADD', status: 'active' }], Expired: [], Unknown: [] },
35
+ beta: { Active: [], Expired: [{ code: 'OLD', status: 'expired' }], Unknown: [] }
36
+ });
37
+
38
+ const originalFetch = global.fetch;
39
+ global.fetch = async (input) => {
40
+ const url = new URL(String(input));
41
+ if (phase === 3 && url.pathname.endsWith('/beta')) return response({ success: false }, 503);
42
+ if (url.pathname.endsWith('/codes')) return response(directory());
43
+ if (url.pathname.endsWith('/codes/status')) return response({ success: true, data: { generic: { ok: true }, specialized: [] } });
44
+ const game = url.pathname.split('/').pop();
45
+ if (datasets()[game]) return response(datasets()[game]);
46
+ return response({ success: false }, 404);
47
+ };
48
+
49
+ try {
50
+ const first = await syncGameCodeCache({ dataDir, apiBase: 'https://example.test', apiPath: '/v5/games/api/codes', retries: 0, delayMs: 0 });
51
+ assert.equal(first.success, true);
52
+ assert.equal(fs.existsSync(path.join(dataDir, 'games', 'alpha.json')), true);
53
+ let alpha = JSON.parse(await fs.promises.readFile(path.join(dataDir, 'games', 'alpha.json'), 'utf8'));
54
+ assert.deepEqual(alpha.Active.map((row) => row.code), ['KEEP', 'REMOVE']);
55
+
56
+ phase = 2;
57
+ const second = await syncGameCodeCache({ dataDir, apiBase: 'https://example.test', apiPath: '/v5/games/api/codes', retries: 0, delayMs: 0 });
58
+ assert.equal(second.success, true);
59
+ alpha = JSON.parse(await fs.promises.readFile(path.join(dataDir, 'games', 'alpha.json'), 'utf8'));
60
+ assert.deepEqual(alpha.Active.map((row) => row.code), ['KEEP', 'ADD']);
61
+ assert.equal(alpha.Active[0].notes, 'changed');
62
+ assert.equal(alpha.Active.some((row) => row.code === 'REMOVE'), false);
63
+
64
+ const beforeFailure = await fs.promises.readFile(path.join(dataDir, 'games', 'alpha.json'), 'utf8');
65
+ phase = 3;
66
+ await assert.rejects(
67
+ () => syncGameCodeCache({ dataDir, apiBase: 'https://example.test', apiPath: '/v5/games/api/codes', retries: 0, delayMs: 0 }),
68
+ /503/
69
+ );
70
+ const afterFailure = await fs.promises.readFile(path.join(dataDir, 'games', 'alpha.json'), 'utf8');
71
+ assert.equal(afterFailure, beforeFailure, 'failed partial sync must preserve last-good code cache');
72
+
73
+ const status = JSON.parse(await fs.promises.readFile(path.join(dataDir, 'status.json'), 'utf8'));
74
+ assert.equal(status.generic.ok, true);
75
+ console.log('game-code-cache smoke test passed');
76
+ } finally {
77
+ global.fetch = originalFetch;
78
+ await fs.promises.rm(temp, { recursive: true, force: true });
79
+ }
80
+ }
81
+
82
+ main().catch((error) => {
83
+ console.error(error);
84
+ process.exitCode = 1;
85
+ });
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ const { syncGamingInfoCacheSafely } = require('./sync-gaming-info-cache');
4
+ const { syncGameCodeCache } = require('./sync-game-code-cache');
5
+
6
+ async function main() {
7
+ // The gaming-info phase temporarily moves data/game-codes out of the way,
8
+ // mirrors data/, then restores the previous code cache before code syncing.
9
+ // If the code API fails afterwards, its last-good fallback is still present.
10
+ const gamingInfos = await syncGamingInfoCacheSafely();
11
+ const gameCodes = await syncGameCodeCache();
12
+ console.log(JSON.stringify({ success: true, gamingInfos, gameCodes }, null, 2));
13
+ }
14
+
15
+ main().catch((error) => {
16
+ console.error(`[gaming-infos combined cache] ${error.stack || error.message || error}`);
17
+ process.exitCode = 1;
18
+ });