@nekosuneprojects/gaming-infos 2.2.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -3
- package/README.md +62 -495
- package/index.js +3 -223
- package/package.json +53 -46
- package/src/providers/vrchat.js +11 -0
- package/src/providers/warframe.js +7 -0
- package/src/registry.js +23 -0
- package/.github/workflows/npm-publish-github-packages.yml +0 -56
- package/.github/workflows/npm-publish.yml +0 -71
- package/.github/workflows/sync-api-cache.yml +0 -72
- package/CACHE_SYNC.md +0 -172
- package/CONTRIBUTING.md +0 -217
- package/DATA.md +0 -292
- package/data/arknightsendfield/meta.json +0 -14
- package/data/fortnite/meta.json +0 -14
- package/data/game-codes/.gitkeep +0 -0
- package/data/genshinimpact/characters/albedo.json +0 -11
- package/data/genshinimpact/characters/alhaitham.json +0 -12
- package/data/genshinimpact/characters/aloy.json +0 -11
- package/data/genshinimpact/characters/amber.json +0 -12
- package/data/genshinimpact/characters/aratakiitto.json +0 -12
- package/data/genshinimpact/characters/arlecchino.json +0 -12
- package/data/genshinimpact/characters/baizhu.json +0 -12
- package/data/genshinimpact/characters/barbara.json +0 -11
- package/data/genshinimpact/characters/beidou.json +0 -12
- package/data/genshinimpact/characters/bennett.json +0 -11
- package/data/genshinimpact/characters/candace.json +0 -12
- package/data/genshinimpact/characters/charlotte.json +0 -11
- package/data/genshinimpact/characters/chasca.json +0 -11
- package/data/genshinimpact/characters/chevreuse.json +0 -11
- package/data/genshinimpact/characters/chiori.json +0 -11
- package/data/genshinimpact/characters/chongyun.json +0 -12
- package/data/genshinimpact/characters/citlali.json +0 -11
- package/data/genshinimpact/characters/clorinde.json +0 -11
- package/data/genshinimpact/characters/collei.json +0 -12
- package/data/genshinimpact/characters/cyno.json +0 -12
- package/data/genshinimpact/characters/dahlia.json +0 -10
- package/data/genshinimpact/characters/dehya.json +0 -11
- package/data/genshinimpact/characters/diluc.json +0 -11
- package/data/genshinimpact/characters/diona.json +0 -11
- package/data/genshinimpact/characters/dori.json +0 -11
- package/data/genshinimpact/characters/durin.json +0 -11
- package/data/genshinimpact/characters/emilie.json +0 -11
- package/data/genshinimpact/characters/escoffier.json +0 -12
- package/data/genshinimpact/characters/eula.json +0 -11
- package/data/genshinimpact/characters/faruzan.json +0 -12
- package/data/genshinimpact/characters/fischl.json +0 -12
- package/data/genshinimpact/characters/freminet.json +0 -11
- package/data/genshinimpact/characters/furina.json +0 -12
- package/data/genshinimpact/characters/gaming.json +0 -11
- package/data/genshinimpact/characters/ganyu.json +0 -11
- package/data/genshinimpact/characters/gorou.json +0 -12
- package/data/genshinimpact/characters/hutao.json +0 -12
- package/data/genshinimpact/characters/iansan.json +0 -11
- package/data/genshinimpact/characters/ineffa.json +0 -11
- package/data/genshinimpact/characters/jean.json +0 -11
- package/data/genshinimpact/characters/kachina.json +0 -11
- package/data/genshinimpact/characters/kaedeharakazuha.json +0 -12
- package/data/genshinimpact/characters/kaeya.json +0 -11
- package/data/genshinimpact/characters/kamisatoayaka.json +0 -11
- package/data/genshinimpact/characters/kamisatoayato.json +0 -11
- package/data/genshinimpact/characters/kaveh.json +0 -12
- package/data/genshinimpact/characters/keqing.json +0 -11
- package/data/genshinimpact/characters/kinich.json +0 -11
- package/data/genshinimpact/characters/kirara.json +0 -11
- package/data/genshinimpact/characters/klee.json +0 -11
- package/data/genshinimpact/characters/kujousara.json +0 -11
- package/data/genshinimpact/characters/kukishinobu.json +0 -12
- package/data/genshinimpact/characters/lanyan.json +0 -11
- package/data/genshinimpact/characters/layla.json +0 -11
- package/data/genshinimpact/characters/lisa.json +0 -12
- package/data/genshinimpact/characters/lohen.json +0 -11
- package/data/genshinimpact/characters/lynette.json +0 -11
- package/data/genshinimpact/characters/lyney.json +0 -11
- package/data/genshinimpact/characters/mavuika.json +0 -11
- package/data/genshinimpact/characters/mikaschmidt.json +0 -11
- package/data/genshinimpact/characters/mona.json +0 -12
- package/data/genshinimpact/characters/mualani.json +0 -11
- package/data/genshinimpact/characters/nahida.json +0 -12
- package/data/genshinimpact/characters/navia.json +0 -11
- package/data/genshinimpact/characters/neuvillette.json +0 -11
- package/data/genshinimpact/characters/nilou.json +0 -11
- package/data/genshinimpact/characters/ningguang.json +0 -11
- package/data/genshinimpact/characters/noelle.json +0 -12
- package/data/genshinimpact/characters/ororon.json +0 -11
- package/data/genshinimpact/characters/prune.json +0 -10
- package/data/genshinimpact/characters/qiqi.json +0 -12
- package/data/genshinimpact/characters/raidenshogun.json +0 -12
- package/data/genshinimpact/characters/razor.json +0 -11
- package/data/genshinimpact/characters/rosaria.json +0 -12
- package/data/genshinimpact/characters/sangonomiyakokomi.json +0 -12
- package/data/genshinimpact/characters/sayu.json +0 -11
- package/data/genshinimpact/characters/sethos.json +0 -11
- package/data/genshinimpact/characters/shenhe.json +0 -12
- package/data/genshinimpact/characters/shikanoinheizou.json +0 -12
- package/data/genshinimpact/characters/sigewinne.json +0 -11
- package/data/genshinimpact/characters/skirk.json +0 -11
- package/data/genshinimpact/characters/sucrose.json +0 -12
- package/data/genshinimpact/characters/thoma.json +0 -11
- package/data/genshinimpact/characters/tighnari.json +0 -12
- package/data/genshinimpact/characters/varesa.json +0 -11
- package/data/genshinimpact/characters/varka.json +0 -10
- package/data/genshinimpact/characters/venti.json +0 -12
- package/data/genshinimpact/characters/wanderer.json +0 -11
- package/data/genshinimpact/characters/wriothesley.json +0 -12
- package/data/genshinimpact/characters/xiangling.json +0 -12
- package/data/genshinimpact/characters/xianyun.json +0 -11
- package/data/genshinimpact/characters/xiao.json +0 -11
- package/data/genshinimpact/characters/xilonen.json +0 -11
- package/data/genshinimpact/characters/xingqiu.json +0 -12
- package/data/genshinimpact/characters/xinyan.json +0 -12
- package/data/genshinimpact/characters/yaemiko.json +0 -12
- package/data/genshinimpact/characters/yanfei.json +0 -12
- package/data/genshinimpact/characters/yaoyao.json +0 -12
- package/data/genshinimpact/characters/yelan.json +0 -11
- package/data/genshinimpact/characters/yoimiya.json +0 -12
- package/data/genshinimpact/characters/yumemizukimizuki.json +0 -11
- package/data/genshinimpact/characters/yunjin.json +0 -11
- package/data/genshinimpact/characters/zhongli.json +0 -11
- package/data/genshinimpact/characters/zibai.json +0 -11
- package/data/genshinimpact/meta.json +0 -14
- package/data/honkaistarrail/characters/acheron.json +0 -12
- package/data/honkaistarrail/characters/aglaea.json +0 -12
- package/data/honkaistarrail/characters/anaxa.json +0 -12
- package/data/honkaistarrail/characters/argenti.json +0 -12
- package/data/honkaistarrail/characters/arlan.json +0 -12
- package/data/honkaistarrail/characters/ashveil.json +0 -12
- package/data/honkaistarrail/characters/asta.json +0 -12
- package/data/honkaistarrail/characters/aventurine.json +0 -12
- package/data/honkaistarrail/characters/bailu.json +0 -12
- package/data/honkaistarrail/characters/blackswan.json +0 -12
- package/data/honkaistarrail/characters/blade.json +0 -12
- package/data/honkaistarrail/characters/boothill.json +0 -12
- package/data/honkaistarrail/characters/bronya.json +0 -12
- package/data/honkaistarrail/characters/castorice.json +0 -12
- package/data/honkaistarrail/characters/cerydra.json +0 -12
- package/data/honkaistarrail/characters/cipher.json +0 -12
- package/data/honkaistarrail/characters/clara.json +0 -12
- package/data/honkaistarrail/characters/constance.json +0 -12
- package/data/honkaistarrail/characters/cyrene.json +0 -12
- package/data/honkaistarrail/characters/danheng.json +0 -12
- package/data/honkaistarrail/characters/drratio.json +0 -12
- package/data/honkaistarrail/characters/feixiao.json +0 -12
- package/data/honkaistarrail/characters/firefly.json +0 -12
- package/data/honkaistarrail/characters/fuxuan.json +0 -12
- package/data/honkaistarrail/characters/gallagher.json +0 -12
- package/data/honkaistarrail/characters/gepard.json +0 -12
- package/data/honkaistarrail/characters/guinaifen.json +0 -12
- package/data/honkaistarrail/characters/hanya.json +0 -12
- package/data/honkaistarrail/characters/herta.json +0 -12
- package/data/honkaistarrail/characters/himeko.json +0 -13
- package/data/honkaistarrail/characters/hook.json +0 -12
- package/data/honkaistarrail/characters/huohuo.json +0 -12
- package/data/honkaistarrail/characters/hyacine.json +0 -12
- package/data/honkaistarrail/characters/hysilens.json +0 -12
- package/data/honkaistarrail/characters/jade.json +0 -12
- package/data/honkaistarrail/characters/jiaoqiu.json +0 -12
- package/data/honkaistarrail/characters/jingliu.json +0 -12
- package/data/honkaistarrail/characters/jingyuan.json +0 -12
- package/data/honkaistarrail/characters/kafka.json +0 -12
- package/data/honkaistarrail/characters/lingsha.json +0 -12
- package/data/honkaistarrail/characters/lisushang.json +0 -12
- package/data/honkaistarrail/characters/luka.json +0 -12
- package/data/honkaistarrail/characters/luocha.json +0 -12
- package/data/honkaistarrail/characters/lynx.json +0 -12
- package/data/honkaistarrail/characters/march7th.json +0 -12
- package/data/honkaistarrail/characters/misha.json +0 -12
- package/data/honkaistarrail/characters/moze.json +0 -12
- package/data/honkaistarrail/characters/mydei.json +0 -12
- package/data/honkaistarrail/characters/natasha.json +0 -12
- package/data/honkaistarrail/characters/pela.json +0 -12
- package/data/honkaistarrail/characters/phainon.json +0 -12
- package/data/honkaistarrail/characters/qingque.json +0 -12
- package/data/honkaistarrail/characters/rappa.json +0 -12
- package/data/honkaistarrail/characters/robin.json +0 -12
- package/data/honkaistarrail/characters/ruanmei.json +0 -12
- package/data/honkaistarrail/characters/sampo.json +0 -12
- package/data/honkaistarrail/characters/seele.json +0 -12
- package/data/honkaistarrail/characters/serval.json +0 -12
- package/data/honkaistarrail/characters/silverwolf.json +0 -12
- package/data/honkaistarrail/characters/sparkle.json +0 -12
- package/data/honkaistarrail/characters/tingyun.json +0 -12
- package/data/honkaistarrail/characters/topaz.json +0 -12
- package/data/honkaistarrail/characters/tribbie.json +0 -12
- package/data/honkaistarrail/characters/weltyang.json +0 -12
- package/data/honkaistarrail/characters/xueyi.json +0 -12
- package/data/honkaistarrail/characters/yanqing.json +0 -12
- package/data/honkaistarrail/characters/yaoguang.json +0 -12
- package/data/honkaistarrail/characters/yukong.json +0 -12
- package/data/honkaistarrail/characters/yunli.json +0 -12
- package/data/honkaistarrail/meta.json +0 -14
- package/data/nte/characters/adler.json +0 -14
- package/data/nte/characters/aurelia.json +0 -14
- package/data/nte/characters/baicang.json +0 -14
- package/data/nte/characters/chaos.json +0 -14
- package/data/nte/characters/chiz.json +0 -14
- package/data/nte/characters/daffodill.json +0 -14
- package/data/nte/characters/edgar.json +0 -14
- package/data/nte/characters/esperzero.json +0 -14
- package/data/nte/characters/fadia.json +0 -14
- package/data/nte/characters/haniel.json +0 -14
- package/data/nte/characters/hathor.json +0 -14
- package/data/nte/characters/hotori.json +0 -14
- package/data/nte/characters/iroi.json +0 -14
- package/data/nte/characters/jiuyuan.json +0 -14
- package/data/nte/characters/lacrimosa.json +0 -14
- package/data/nte/characters/mint.json +0 -14
- package/data/nte/characters/nanally.json +0 -14
- package/data/nte/characters/sakiri.json +0 -14
- package/data/nte/characters/shinku.json +0 -14
- package/data/nte/characters/skia.json +0 -14
- package/data/nte/meta.json +0 -14
- package/data/toweroffantasy/meta.json +0 -14
- package/data/vrchat/avatars/.gitkeep +0 -0
- package/data/vrchat/groups/clubzodiac.json +0 -7
- package/data/vrchat/groups/dirtydancers.json +0 -7
- package/data/vrchat/groups/drumsynews.json +0 -7
- package/data/vrchat/groups/ebola20.json +0 -7
- package/data/vrchat/groups/internationaldancersassociation.json +0 -7
- package/data/vrchat/groups/kannagang.json +0 -6
- package/data/vrchat/groups/lolisquad.json +0 -7
- package/data/vrchat/groups/nekonightsnightclub.json +0 -7
- package/data/vrchat/groups/nekostudios.json +0 -7
- package/data/vrchat/groups/nekosunecommunity.json +0 -7
- package/data/vrchat/groups/voidclub.json +0 -7
- package/data/vrchat/groups/voltdanceclub.json +0 -7
- package/data/vrchat/groups/vrceventsgroup.json +0 -7
- package/data/vrchat/groups/vrchatcommunitydiscord.json +0 -6
- package/data/vrchat/meta.json +0 -13
- package/data/vrchat/players/alto.json +0 -9
- package/data/vrchat/players/arcadum.json +0 -10
- package/data/vrchat/players/chairhandler.json +0 -9
- package/data/vrchat/players/chipz.json +0 -9
- package/data/vrchat/players/hikarivr.json +0 -10
- package/data/vrchat/players/kimple.json +0 -10
- package/data/vrchat/players/linkzr.json +0 -8
- package/data/vrchat/players/nagzz21.json +0 -9
- package/data/vrchat/players/nebride.json +0 -10
- package/data/vrchat/players/nekosunevr.json +0 -10
- package/data/vrchat/players/pokelawls.json +0 -9
- package/data/vrchat/players/qwonk.json +0 -8
- package/data/vrchat/players/roflgator.json +0 -10
- package/data/vrchat/players/scifri.json +0 -7
- package/data/vrchat/players/sodapoppin.json +0 -9
- package/data/vrchat/players/woops.json +0 -9
- package/data/vrchat/players/zentreya.json +0 -10
- package/data/vrchat/worlds/ascensionacademy.json +0 -7
- package/data/vrchat/worlds/clubvgl.json +0 -8
- package/data/vrchat/worlds/fightfinder.json +0 -8
- package/data/vrchat/worlds/hangouthouse.json +0 -7
- package/data/vrchat/worlds/nekostudioshalloween.json +0 -9
- package/data/vrchat/worlds/nekostudiosnewyear.json +0 -9
- package/data/vrchat/worlds/nekostudiosxmas.json +0 -9
- package/data/vrchat/worlds/nekosunevranimations2023.json +0 -9
- package/data/vrchat/worlds/nekosunevranimations2024.json +0 -9
- package/data/vrchat/worlds/nekosunevranimations2025.json +0 -9
- package/data/vrchat/worlds/rumhamsavatarworld.json +0 -7
- package/data/vrchat/worlds/smewsranch.json +0 -7
- package/data/vrchat/worlds/smewsroom.json +0 -7
- package/data/vrchat/worlds/sunsetbar.json +0 -7
- package/data/vrchat/worlds/theblackcat.json +0 -9
- package/data/vrchat/worlds/thegreatpug.json +0 -8
- package/data/vrchat/worlds/themidnightbar.json +0 -7
- package/data/vrchat/worlds/voidclub.json +0 -7
- package/data/vrchat/worlds/voltdanceclub.json +0 -7
- package/data/vrchat/worlds/vrchathome.json +0 -8
- package/data/vrchat/worlds/whatalegend.json +0 -6
- package/data/warframe/meta.json +0 -14
- package/data/wutheringwaves/meta.json +0 -14
- package/data/zenlesszonezero/meta.json +0 -14
- package/gamecodes.js +0 -178
- package/scripts/cache-sync-smoke.js +0 -92
- package/scripts/game-code-cache-smoke.js +0 -85
- package/scripts/sync-all-cache.js +0 -18
- package/scripts/sync-api-cache.js +0 -319
- package/scripts/sync-game-code-cache.js +0 -244
- package/scripts/sync-gaming-info-cache.js +0 -46
|
@@ -1,85 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,319 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const crypto = require('crypto');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
|
|
7
|
-
const ROOT_DIR = path.resolve(__dirname, '..');
|
|
8
|
-
const DEFAULT_DATA_DIR = path.join(ROOT_DIR, 'data');
|
|
9
|
-
const DEFAULT_API_BASE = 'https://api.nekosunevr.co.uk';
|
|
10
|
-
const DEFAULT_API_PATH = '/v5/games/api/gaming-infos';
|
|
11
|
-
|
|
12
|
-
// APINODE currently exposes these game/type pairs. The mirror is authoritative:
|
|
13
|
-
// after a fully successful sync, anything no longer returned by APINODE is removed
|
|
14
|
-
// from data/ so the bundled fallback matches the API snapshot exactly.
|
|
15
|
-
const DEFAULT_MANIFEST = Object.freeze({
|
|
16
|
-
vrchat: ['players', 'groups', 'worlds', 'avatars'],
|
|
17
|
-
genshinimpact: ['characters'],
|
|
18
|
-
honkaistarrail: ['characters'],
|
|
19
|
-
nte: ['characters'],
|
|
20
|
-
wutheringwaves: ['characters'],
|
|
21
|
-
warframe: ['characters'],
|
|
22
|
-
fortnite: ['characters'],
|
|
23
|
-
zenlesszonezero: ['characters'],
|
|
24
|
-
toweroffantasy: ['characters'],
|
|
25
|
-
arknightsendfield: ['characters']
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
function numberOption(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) {
|
|
29
|
-
const parsed = Number(value);
|
|
30
|
-
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function sleep(ms) {
|
|
34
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function normalizeBase(value) {
|
|
38
|
-
return String(value || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function normalizeApiPath(value) {
|
|
42
|
-
const raw = String(value || DEFAULT_API_PATH).trim();
|
|
43
|
-
return `/${raw.replace(/^\/+|\/+$/g, '')}`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function safeSlug(value, label = 'slug') {
|
|
47
|
-
const slug = String(value || '').trim().toLowerCase();
|
|
48
|
-
if (!slug || !/^[a-z0-9][a-z0-9._-]*$/.test(slug)) {
|
|
49
|
-
throw new Error(`Invalid ${label}: ${JSON.stringify(value)}`);
|
|
50
|
-
}
|
|
51
|
-
return slug;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function fallbackSlug(name) {
|
|
55
|
-
const slug = String(name || '')
|
|
56
|
-
.normalize('NFKD')
|
|
57
|
-
.replace(/[\u0300-\u036f]/g, '')
|
|
58
|
-
.toLowerCase()
|
|
59
|
-
.replace(/[^a-z0-9]+/g, '')
|
|
60
|
-
.trim();
|
|
61
|
-
return safeSlug(slug, 'derived slug');
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function loadManifest(raw = process.env.GAMING_INFOS_CACHE_MANIFEST_JSON) {
|
|
65
|
-
if (!raw) return { ...DEFAULT_MANIFEST };
|
|
66
|
-
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
67
|
-
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
|
68
|
-
throw new Error('GAMING_INFOS_CACHE_MANIFEST_JSON must be a JSON object');
|
|
69
|
-
}
|
|
70
|
-
const result = {};
|
|
71
|
-
for (const [gameRaw, typesRaw] of Object.entries(parsed)) {
|
|
72
|
-
const game = safeSlug(gameRaw, 'game');
|
|
73
|
-
if (!Array.isArray(typesRaw) || !typesRaw.length) throw new Error(`No types configured for ${game}`);
|
|
74
|
-
result[game] = [...new Set(typesRaw.map((type) => safeSlug(type, 'type')))];
|
|
75
|
-
}
|
|
76
|
-
return result;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function requestJson(url, options = {}) {
|
|
80
|
-
const timeoutMs = numberOption(options.timeoutMs ?? process.env.GAMING_INFOS_CACHE_TIMEOUT_MS, 15000, 1000, 120000);
|
|
81
|
-
const retries = numberOption(options.retries ?? process.env.GAMING_INFOS_CACHE_RETRIES, 2, 0, 5);
|
|
82
|
-
const retryDelayMs = numberOption(options.retryDelayMs ?? process.env.GAMING_INFOS_CACHE_RETRY_DELAY_MS, 750, 0, 30000);
|
|
83
|
-
let lastError;
|
|
84
|
-
|
|
85
|
-
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
86
|
-
const controller = new AbortController();
|
|
87
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
88
|
-
try {
|
|
89
|
-
const response = await fetch(url, {
|
|
90
|
-
signal: controller.signal,
|
|
91
|
-
headers: {
|
|
92
|
-
Accept: 'application/json',
|
|
93
|
-
'User-Agent': process.env.GAMING_INFOS_CACHE_USER_AGENT || 'NekoSuneProjects-gaming-infos-cache/1.0'
|
|
94
|
-
}
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
if (!response.ok) {
|
|
98
|
-
const error = new Error(`HTTP ${response.status} from ${url}`);
|
|
99
|
-
error.status = response.status;
|
|
100
|
-
throw error;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const body = await response.json();
|
|
104
|
-
if (!body || body.success === false) {
|
|
105
|
-
throw new Error(`API returned an unsuccessful response for ${url}`);
|
|
106
|
-
}
|
|
107
|
-
return body.data !== undefined ? body.data : body;
|
|
108
|
-
} catch (error) {
|
|
109
|
-
lastError = error;
|
|
110
|
-
const status = Number(error.status || 0);
|
|
111
|
-
const retryable = !status || status === 408 || status === 425 || status === 429 || status >= 500;
|
|
112
|
-
if (attempt >= retries || !retryable) break;
|
|
113
|
-
await sleep(retryDelayMs * (attempt + 1));
|
|
114
|
-
} finally {
|
|
115
|
-
clearTimeout(timer);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
throw lastError || new Error(`Failed to fetch ${url}`);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function apiUrl(base, apiPath, suffix = '') {
|
|
123
|
-
return `${normalizeBase(base)}${normalizeApiPath(apiPath)}${suffix}`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function writeJson(filePath, value) {
|
|
127
|
-
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
128
|
-
await fs.promises.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function walkFiles(dir, root = dir, out = new Map()) {
|
|
132
|
-
if (!fs.existsSync(dir)) return out;
|
|
133
|
-
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
134
|
-
for (const entry of entries) {
|
|
135
|
-
const full = path.join(dir, entry.name);
|
|
136
|
-
if (entry.isDirectory()) await walkFiles(full, root, out);
|
|
137
|
-
else if (entry.isFile() && entry.name !== '.cache-manifest.json') {
|
|
138
|
-
const relative = path.relative(root, full).split(path.sep).join('/');
|
|
139
|
-
out.set(relative, await fs.promises.readFile(full));
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return out;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function hashFiles(files) {
|
|
146
|
-
const hash = crypto.createHash('sha256');
|
|
147
|
-
for (const [name, content] of [...files.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
148
|
-
hash.update(name);
|
|
149
|
-
hash.update('\0');
|
|
150
|
-
hash.update(content);
|
|
151
|
-
hash.update('\0');
|
|
152
|
-
}
|
|
153
|
-
return hash.digest('hex');
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async function snapshotHash(dir) {
|
|
157
|
-
return hashFiles(await walkFiles(dir));
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function diffStats(oldFiles, newFiles) {
|
|
161
|
-
let added = 0;
|
|
162
|
-
let removed = 0;
|
|
163
|
-
let changed = 0;
|
|
164
|
-
let unchanged = 0;
|
|
165
|
-
|
|
166
|
-
for (const [name, content] of newFiles) {
|
|
167
|
-
if (!oldFiles.has(name)) added += 1;
|
|
168
|
-
else if (!oldFiles.get(name).equals(content)) changed += 1;
|
|
169
|
-
else unchanged += 1;
|
|
170
|
-
}
|
|
171
|
-
for (const name of oldFiles.keys()) if (!newFiles.has(name)) removed += 1;
|
|
172
|
-
return { added, changed, removed, unchanged, total: newFiles.size };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async function replaceSnapshot(nextDir, dataDir) {
|
|
176
|
-
const backupDir = `${dataDir}.cache-backup-${process.pid}`;
|
|
177
|
-
await fs.promises.rm(backupDir, { recursive: true, force: true });
|
|
178
|
-
const hadCurrent = fs.existsSync(dataDir);
|
|
179
|
-
|
|
180
|
-
if (hadCurrent) await fs.promises.rename(dataDir, backupDir);
|
|
181
|
-
try {
|
|
182
|
-
await fs.promises.rename(nextDir, dataDir);
|
|
183
|
-
await fs.promises.rm(backupDir, { recursive: true, force: true });
|
|
184
|
-
} catch (error) {
|
|
185
|
-
if (!fs.existsSync(dataDir) && hadCurrent && fs.existsSync(backupDir)) {
|
|
186
|
-
await fs.promises.rename(backupDir, dataDir);
|
|
187
|
-
}
|
|
188
|
-
throw error;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function normalizedEntry(item) {
|
|
193
|
-
if (!item || Array.isArray(item) || typeof item !== 'object') throw new Error('API list contained a non-object entry');
|
|
194
|
-
const slug = safeSlug(item.slug || fallbackSlug(item.name), 'entry slug');
|
|
195
|
-
return { ...item, slug };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function buildNextSnapshot(options) {
|
|
199
|
-
const { apiBase, apiPath, dataDir, manifest } = options;
|
|
200
|
-
const parentDir = path.dirname(dataDir);
|
|
201
|
-
const nextDir = path.join(parentDir, `.gaming-infos-cache-next-${process.pid}-${Date.now()}`);
|
|
202
|
-
await fs.promises.rm(nextDir, { recursive: true, force: true });
|
|
203
|
-
await fs.promises.mkdir(nextDir, { recursive: true });
|
|
204
|
-
|
|
205
|
-
const rootData = await requestJson(apiUrl(apiBase, apiPath), options);
|
|
206
|
-
if (!Array.isArray(rootData) || !rootData.length) {
|
|
207
|
-
throw new Error('Gaming-infos root endpoint returned no games; refusing to replace the fallback cache');
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const counts = {};
|
|
211
|
-
for (const [game, types] of Object.entries(manifest)) {
|
|
212
|
-
const meta = await requestJson(apiUrl(apiBase, apiPath, `/${encodeURIComponent(game)}/meta/${encodeURIComponent(game)}`), options);
|
|
213
|
-
if (!meta || Array.isArray(meta) || typeof meta !== 'object') throw new Error(`Invalid meta payload for ${game}`);
|
|
214
|
-
await writeJson(path.join(nextDir, game, 'meta.json'), meta);
|
|
215
|
-
counts[game] = { meta: 1 };
|
|
216
|
-
|
|
217
|
-
for (const type of types) {
|
|
218
|
-
const list = await requestJson(apiUrl(apiBase, apiPath, `/${encodeURIComponent(game)}/${encodeURIComponent(type)}`), options);
|
|
219
|
-
if (!Array.isArray(list)) throw new Error(`Expected an array for ${game}/${type}`);
|
|
220
|
-
|
|
221
|
-
const seen = new Set();
|
|
222
|
-
counts[game][type] = list.length;
|
|
223
|
-
for (const rawItem of list) {
|
|
224
|
-
const item = normalizedEntry(rawItem);
|
|
225
|
-
if (seen.has(item.slug)) throw new Error(`Duplicate slug ${item.slug} in ${game}/${type}`);
|
|
226
|
-
seen.add(item.slug);
|
|
227
|
-
await writeJson(path.join(nextDir, game, type, `${item.slug}.json`), item);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return { nextDir, rootGameCount: rootData.length, counts };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
async function syncApiCache(options = {}) {
|
|
236
|
-
if (typeof fetch !== 'function') throw new Error('Node.js 18+ is required because this sync uses the built-in fetch API');
|
|
237
|
-
|
|
238
|
-
const dataDir = path.resolve(options.dataDir || process.env.GAMING_INFOS_CACHE_DIR || DEFAULT_DATA_DIR);
|
|
239
|
-
const apiBase = options.apiBase || process.env.GAMING_INFOS_API_BASE || DEFAULT_API_BASE;
|
|
240
|
-
const apiPath = options.apiPath || process.env.GAMING_INFOS_API_PATH || DEFAULT_API_PATH;
|
|
241
|
-
const manifest = options.manifest || loadManifest();
|
|
242
|
-
const requestOptions = {
|
|
243
|
-
...options,
|
|
244
|
-
apiBase,
|
|
245
|
-
apiPath,
|
|
246
|
-
dataDir,
|
|
247
|
-
manifest
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
let nextDir;
|
|
251
|
-
try {
|
|
252
|
-
const built = await buildNextSnapshot(requestOptions);
|
|
253
|
-
nextDir = built.nextDir;
|
|
254
|
-
const oldFiles = await walkFiles(dataDir);
|
|
255
|
-
const newFiles = await walkFiles(nextDir);
|
|
256
|
-
const oldHash = hashFiles(oldFiles);
|
|
257
|
-
const newHash = hashFiles(newFiles);
|
|
258
|
-
const stats = diffStats(oldFiles, newFiles);
|
|
259
|
-
|
|
260
|
-
if (oldFiles.size && oldHash === newHash) {
|
|
261
|
-
await fs.promises.rm(nextDir, { recursive: true, force: true });
|
|
262
|
-
return {
|
|
263
|
-
success: true,
|
|
264
|
-
changed: false,
|
|
265
|
-
contentHash: newHash,
|
|
266
|
-
source: apiUrl(apiBase, apiPath),
|
|
267
|
-
games: Object.keys(manifest),
|
|
268
|
-
counts: built.counts,
|
|
269
|
-
stats
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const cacheManifest = {
|
|
274
|
-
schemaVersion: 1,
|
|
275
|
-
source: apiUrl(apiBase, apiPath),
|
|
276
|
-
syncedAt: new Date().toISOString(),
|
|
277
|
-
contentHash: newHash,
|
|
278
|
-
rootGameCount: built.rootGameCount,
|
|
279
|
-
games: Object.fromEntries(Object.entries(manifest).map(([game, types]) => [game, { types, counts: built.counts[game] }])),
|
|
280
|
-
stats
|
|
281
|
-
};
|
|
282
|
-
await writeJson(path.join(nextDir, '.cache-manifest.json'), cacheManifest);
|
|
283
|
-
await replaceSnapshot(nextDir, dataDir);
|
|
284
|
-
nextDir = null;
|
|
285
|
-
|
|
286
|
-
return {
|
|
287
|
-
success: true,
|
|
288
|
-
changed: true,
|
|
289
|
-
contentHash: newHash,
|
|
290
|
-
source: cacheManifest.source,
|
|
291
|
-
games: Object.keys(manifest),
|
|
292
|
-
counts: built.counts,
|
|
293
|
-
stats
|
|
294
|
-
};
|
|
295
|
-
} catch (error) {
|
|
296
|
-
if (nextDir) await fs.promises.rm(nextDir, { recursive: true, force: true }).catch(() => {});
|
|
297
|
-
throw error;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
async function main() {
|
|
302
|
-
const result = await syncApiCache();
|
|
303
|
-
console.log(JSON.stringify(result, null, 2));
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
if (require.main === module) {
|
|
307
|
-
main().catch((error) => {
|
|
308
|
-
console.error(`[gaming-infos cache] ${error.stack || error.message || error}`);
|
|
309
|
-
process.exitCode = 1;
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
module.exports = {
|
|
314
|
-
DEFAULT_MANIFEST,
|
|
315
|
-
loadManifest,
|
|
316
|
-
normalizedEntry,
|
|
317
|
-
snapshotHash,
|
|
318
|
-
syncApiCache
|
|
319
|
-
};
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const crypto = require('crypto');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
|
|
7
|
-
const ROOT_DIR = path.resolve(__dirname, '..');
|
|
8
|
-
const DEFAULT_DATA_DIR = path.join(ROOT_DIR, 'data', 'game-codes');
|
|
9
|
-
const DEFAULT_API_BASE = 'https://api.nekosunevr.co.uk';
|
|
10
|
-
const DEFAULT_API_PATH = '/v5/games/api/codes';
|
|
11
|
-
|
|
12
|
-
function numberOption(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) {
|
|
13
|
-
const parsed = Number(value);
|
|
14
|
-
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function sleep(ms) {
|
|
18
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function normalizeBase(value) {
|
|
22
|
-
return String(value || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function normalizeApiPath(value) {
|
|
26
|
-
const raw = String(value || DEFAULT_API_PATH).trim();
|
|
27
|
-
return `/${raw.replace(/^\/+|\/+$/g, '')}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function requestJson(url, options = {}) {
|
|
31
|
-
const timeoutMs = numberOption(options.timeoutMs ?? process.env.GAME_CODES_CACHE_TIMEOUT_MS, 15000, 1000, 120000);
|
|
32
|
-
const retries = numberOption(options.retries ?? process.env.GAME_CODES_CACHE_RETRIES, 2, 0, 5);
|
|
33
|
-
const retryDelayMs = numberOption(options.retryDelayMs ?? process.env.GAME_CODES_CACHE_RETRY_DELAY_MS, 750, 0, 30000);
|
|
34
|
-
let lastError;
|
|
35
|
-
|
|
36
|
-
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
37
|
-
const controller = new AbortController();
|
|
38
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
39
|
-
try {
|
|
40
|
-
const response = await fetch(url, {
|
|
41
|
-
signal: controller.signal,
|
|
42
|
-
headers: {
|
|
43
|
-
Accept: 'application/json',
|
|
44
|
-
'User-Agent': process.env.GAME_CODES_CACHE_USER_AGENT || 'NekoSuneProjects-gaming-infos-game-codes-cache/1.0'
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
if (!response.ok) {
|
|
48
|
-
const error = new Error(`HTTP ${response.status} from ${url}`);
|
|
49
|
-
error.status = response.status;
|
|
50
|
-
throw error;
|
|
51
|
-
}
|
|
52
|
-
const body = await response.json();
|
|
53
|
-
if (!body || body.success === false) throw new Error(`API returned an unsuccessful response for ${url}`);
|
|
54
|
-
return body;
|
|
55
|
-
} catch (error) {
|
|
56
|
-
lastError = error;
|
|
57
|
-
const status = Number(error.status || 0);
|
|
58
|
-
const retryable = !status || status === 408 || status === 425 || status === 429 || status >= 500;
|
|
59
|
-
if (attempt >= retries || !retryable) break;
|
|
60
|
-
await sleep(retryDelayMs * (attempt + 1));
|
|
61
|
-
} finally {
|
|
62
|
-
clearTimeout(timer);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
throw lastError || new Error(`Failed to fetch ${url}`);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function apiUrl(base, apiPath, suffix = '', query = {}) {
|
|
70
|
-
const url = new URL(`${normalizeBase(base)}${normalizeApiPath(apiPath)}${suffix}`);
|
|
71
|
-
for (const [key, value] of Object.entries(query)) {
|
|
72
|
-
if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value));
|
|
73
|
-
}
|
|
74
|
-
return url.href;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function writeJson(filePath, value) {
|
|
78
|
-
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
79
|
-
await fs.promises.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function walkFiles(dir, root = dir, out = new Map()) {
|
|
83
|
-
if (!fs.existsSync(dir)) return out;
|
|
84
|
-
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
85
|
-
for (const entry of entries) {
|
|
86
|
-
const full = path.join(dir, entry.name);
|
|
87
|
-
if (entry.isDirectory()) await walkFiles(full, root, out);
|
|
88
|
-
else if (entry.isFile() && entry.name !== '.cache-manifest.json') {
|
|
89
|
-
const relative = path.relative(root, full).split(path.sep).join('/');
|
|
90
|
-
out.set(relative, await fs.promises.readFile(full));
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return out;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function hashFiles(files) {
|
|
97
|
-
const hash = crypto.createHash('sha256');
|
|
98
|
-
for (const [name, content] of [...files.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
99
|
-
hash.update(name);
|
|
100
|
-
hash.update('\0');
|
|
101
|
-
hash.update(content);
|
|
102
|
-
hash.update('\0');
|
|
103
|
-
}
|
|
104
|
-
return hash.digest('hex');
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function diffStats(oldFiles, newFiles) {
|
|
108
|
-
let added = 0;
|
|
109
|
-
let removed = 0;
|
|
110
|
-
let changed = 0;
|
|
111
|
-
let unchanged = 0;
|
|
112
|
-
for (const [name, content] of newFiles) {
|
|
113
|
-
if (!oldFiles.has(name)) added += 1;
|
|
114
|
-
else if (!oldFiles.get(name).equals(content)) changed += 1;
|
|
115
|
-
else unchanged += 1;
|
|
116
|
-
}
|
|
117
|
-
for (const name of oldFiles.keys()) if (!newFiles.has(name)) removed += 1;
|
|
118
|
-
return { added, changed, removed, unchanged, total: newFiles.size };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async function replaceSnapshot(nextDir, dataDir) {
|
|
122
|
-
const backupDir = `${dataDir}.cache-backup-${process.pid}`;
|
|
123
|
-
await fs.promises.rm(backupDir, { recursive: true, force: true });
|
|
124
|
-
const hadCurrent = fs.existsSync(dataDir);
|
|
125
|
-
if (hadCurrent) await fs.promises.rename(dataDir, backupDir);
|
|
126
|
-
try {
|
|
127
|
-
await fs.promises.rename(nextDir, dataDir);
|
|
128
|
-
await fs.promises.rm(backupDir, { recursive: true, force: true });
|
|
129
|
-
} catch (error) {
|
|
130
|
-
if (!fs.existsSync(dataDir) && hadCurrent && fs.existsSync(backupDir)) await fs.promises.rename(backupDir, dataDir);
|
|
131
|
-
throw error;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function gameSlug(row) {
|
|
136
|
-
const slug = String(row?.game || '').trim().toLowerCase();
|
|
137
|
-
if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error(`Invalid game slug in code directory: ${JSON.stringify(row?.game)}`);
|
|
138
|
-
return slug;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function validateCodeList(game, payload) {
|
|
142
|
-
if (!payload || Array.isArray(payload) || typeof payload !== 'object') throw new Error(`Invalid code payload for ${game}`);
|
|
143
|
-
if (!Array.isArray(payload.Active) || !Array.isArray(payload.Expired)) throw new Error(`Code payload for ${game} is missing Active/Expired arrays`);
|
|
144
|
-
if (payload.Unknown !== undefined && !Array.isArray(payload.Unknown)) throw new Error(`Unknown codes for ${game} must be an array`);
|
|
145
|
-
return payload;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
async function buildNextSnapshot(options) {
|
|
149
|
-
const { apiBase, apiPath, dataDir } = options;
|
|
150
|
-
const nextDir = path.join(path.dirname(dataDir), `.game-codes-cache-next-${process.pid}-${Date.now()}`);
|
|
151
|
-
await fs.promises.rm(nextDir, { recursive: true, force: true });
|
|
152
|
-
await fs.promises.mkdir(nextDir, { recursive: true });
|
|
153
|
-
|
|
154
|
-
const directory = await requestJson(apiUrl(apiBase, apiPath), options);
|
|
155
|
-
if (!Array.isArray(directory.games) || !directory.games.length) {
|
|
156
|
-
throw new Error('Game code directory returned no games; refusing to replace fallback cache');
|
|
157
|
-
}
|
|
158
|
-
await writeJson(path.join(nextDir, 'directory.json'), directory);
|
|
159
|
-
|
|
160
|
-
// /status is cache-only on APINODE and does not trigger source website requests.
|
|
161
|
-
const statusResponse = await requestJson(apiUrl(apiBase, apiPath, '/status'), options);
|
|
162
|
-
const status = statusResponse.data !== undefined ? statusResponse.data : statusResponse;
|
|
163
|
-
if (!status || typeof status !== 'object') throw new Error('Invalid game code status payload');
|
|
164
|
-
await writeJson(path.join(nextDir, 'status.json'), status);
|
|
165
|
-
|
|
166
|
-
const counts = {};
|
|
167
|
-
const delayMs = numberOption(options.delayMs ?? process.env.GAME_CODES_CACHE_GAME_DELAY_MS, 150, 0, 10000);
|
|
168
|
-
for (let i = 0; i < directory.games.length; i += 1) {
|
|
169
|
-
const game = gameSlug(directory.games[i]);
|
|
170
|
-
const payload = validateCodeList(
|
|
171
|
-
game,
|
|
172
|
-
await requestJson(apiUrl(apiBase, apiPath, `/${encodeURIComponent(game)}`, { includeUnknown: 'true' }), options)
|
|
173
|
-
);
|
|
174
|
-
await writeJson(path.join(nextDir, 'games', `${game}.json`), payload);
|
|
175
|
-
counts[game] = {
|
|
176
|
-
active: payload.Active.length,
|
|
177
|
-
expired: payload.Expired.length,
|
|
178
|
-
unknown: Array.isArray(payload.Unknown) ? payload.Unknown.length : 0,
|
|
179
|
-
total: payload.Active.length + payload.Expired.length + (Array.isArray(payload.Unknown) ? payload.Unknown.length : 0)
|
|
180
|
-
};
|
|
181
|
-
if (i < directory.games.length - 1 && delayMs) await sleep(delayMs);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return { nextDir, directory, counts };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async function syncGameCodeCache(options = {}) {
|
|
188
|
-
if (typeof fetch !== 'function') throw new Error('Node.js 18+ is required because this sync uses the built-in fetch API');
|
|
189
|
-
|
|
190
|
-
const dataDir = path.resolve(options.dataDir || process.env.GAME_CODES_CACHE_DIR || DEFAULT_DATA_DIR);
|
|
191
|
-
const apiBase = options.apiBase || process.env.GAMING_INFOS_API_BASE || DEFAULT_API_BASE;
|
|
192
|
-
const apiPath = options.apiPath || process.env.GAME_CODES_API_PATH || DEFAULT_API_PATH;
|
|
193
|
-
let nextDir;
|
|
194
|
-
|
|
195
|
-
try {
|
|
196
|
-
const built = await buildNextSnapshot({ ...options, dataDir, apiBase, apiPath });
|
|
197
|
-
nextDir = built.nextDir;
|
|
198
|
-
const oldFiles = await walkFiles(dataDir);
|
|
199
|
-
const newFiles = await walkFiles(nextDir);
|
|
200
|
-
const oldHash = hashFiles(oldFiles);
|
|
201
|
-
const newHash = hashFiles(newFiles);
|
|
202
|
-
const stats = diffStats(oldFiles, newFiles);
|
|
203
|
-
|
|
204
|
-
if (oldFiles.size && oldHash === newHash) {
|
|
205
|
-
await fs.promises.rm(nextDir, { recursive: true, force: true });
|
|
206
|
-
return { success: true, changed: false, contentHash: newHash, source: apiUrl(apiBase, apiPath), counts: built.counts, stats };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const manifest = {
|
|
210
|
-
schemaVersion: 1,
|
|
211
|
-
source: apiUrl(apiBase, apiPath),
|
|
212
|
-
syncedAt: new Date().toISOString(),
|
|
213
|
-
contentHash: newHash,
|
|
214
|
-
gameCount: built.directory.games.length,
|
|
215
|
-
counts: built.counts,
|
|
216
|
-
statusIsCacheOnly: true,
|
|
217
|
-
stats
|
|
218
|
-
};
|
|
219
|
-
await writeJson(path.join(nextDir, '.cache-manifest.json'), manifest);
|
|
220
|
-
await replaceSnapshot(nextDir, dataDir);
|
|
221
|
-
nextDir = null;
|
|
222
|
-
return { success: true, changed: true, contentHash: newHash, source: manifest.source, counts: built.counts, stats };
|
|
223
|
-
} catch (error) {
|
|
224
|
-
if (nextDir) await fs.promises.rm(nextDir, { recursive: true, force: true }).catch(() => {});
|
|
225
|
-
throw error;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
async function main() {
|
|
230
|
-
const result = await syncGameCodeCache();
|
|
231
|
-
console.log(JSON.stringify(result, null, 2));
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (require.main === module) {
|
|
235
|
-
main().catch((error) => {
|
|
236
|
-
console.error(`[game-codes cache] ${error.stack || error.message || error}`);
|
|
237
|
-
process.exitCode = 1;
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
module.exports = {
|
|
242
|
-
syncGameCodeCache,
|
|
243
|
-
validateCodeList
|
|
244
|
-
};
|