@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.
- package/.github/workflows/sync-api-cache.yml +72 -0
- package/CACHE_SYNC.md +172 -0
- package/CHANGELOG.md +23 -2
- package/README.md +455 -126
- package/data/arknightsendfield/meta.json +14 -0
- package/data/fortnite/meta.json +14 -0
- package/data/game-codes/.gitkeep +0 -0
- package/data/toweroffantasy/meta.json +14 -0
- package/data/vrchat/avatars/.gitkeep +0 -0
- package/data/warframe/meta.json +14 -0
- package/data/wutheringwaves/meta.json +14 -0
- package/data/zenlesszonezero/meta.json +14 -0
- package/gamecodes.js +178 -0
- package/index.js +34 -17
- package/package.json +20 -2
- package/scripts/cache-sync-smoke.js +92 -0
- package/scripts/game-code-cache-smoke.js +85 -0
- package/scripts/sync-all-cache.js +18 -0
- package/scripts/sync-api-cache.js +319 -0
- package/scripts/sync-game-code-cache.js +244 -0
- package/scripts/sync-gaming-info-cache.js +46 -0
|
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
|
|
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
|
-
|
|
88
|
-
const raw = await fs.promises.readFile(
|
|
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.
|
|
4
|
-
"description": "Multi-game data loader
|
|
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
|
+
});
|