import assert from 'node:assert/strict'; import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { prepareGameDbSource, readGameDbText, sha256 } from './game-db-source.mjs'; // Explicitly select the event to apply; older manual records retain their provenance. const root = new URL('../data/game-events/', import.meta.url); const pointer = JSON.parse(readFileSync(new URL('current.json', root), 'utf8')); assert.match(pointer.file, /^\d{4}-\d{2}-\d{2}\.json$/); const bytes = readFileSync(new URL(pointer.file, root)); assert.equal(sha256(bytes), pointer.sha256, 'Event API snapshot hash mismatch'); const snapshot = JSON.parse(bytes); assert.equal(snapshot.schemaVersion, 1); assert.equal(snapshot.source.kind, 'android-event-api-memory'); assert.equal(snapshot.source.locale, 'kor'); assert(pointer.eventIds.length > 0); assert.equal(new Set(pointer.eventIds).size, pointer.eventIds.length); const db = prepareGameDbSource('kor', { review: true }); const table = name => JSON.parse(readGameDbText(db, name)).map(row => row.data); const posters = table('Poster.json'); const posterNames = new Map(table('LangPoster_Kor.json').map(row => [row.id, row.text])); const cards = new Set(table('Card.json').map(row => row.id)); const characters = new Set(table('Character.json').map(row => row.id)); const songs = new Set(table('Music.json').map(row => row.id)); const iso = value => { assert(Number.isSafeInteger(value) && value > 0, 'Invalid event timestamp'); return new Date(value).toISOString(); }; function uniform(rows, field) { const values = new Set(rows.map(row => row[field])); assert(values.size <= 1, `Calculator cannot represent mixed ${field}`); const value = values.size ? [...values][0] : 0; assert(Number.isInteger(value) && value >= 0); return value / 10; } function project(event) { assert(event && event.marathonInfo, 'Selected event is missing marathon configuration'); // Relay chapters remain historical records until their exact transition semantics are applied. assert.equal(event.marathonInfo.marathonChapters.length, 1, 'Only single-chapter events are supported by this projection'); const chapter = event.marathonInfo.marathonChapters[0]; assert(event.startTime < event.endTime && event.endTime < event.exchangeEndTime); assert.equal(chapter.endTime, event.endTime); const formation = [], featured = [], awakening = {}; for (const row of chapter.marathonLiveMarathonScoreBonuses) { assert.equal(row.cardAttributeType, 0, 'Attribute bonus needs an explicit calculator implementation'); const { characterId, cardId, cardRarity: rarity, cardPotentialUpgradeCount: count } = row; if (characterId && !cardId && !rarity && !count) { assert(characters.has(characterId)); formation.push(row); } else if (cardId && !characterId && !rarity && !count) { assert(cards.has(cardId)); featured.push(row); } else { assert(!characterId && !cardId && Number.isInteger(rarity) && rarity > 0); assert(Number.isInteger(count) && count > 0 && count <= 5); awakening[rarity] ??= [0]; assert.equal(awakening[rarity][count], undefined, 'Duplicate awakening rule'); awakening[rarity][count] = row.marathonScoreQuantityUpPermilUp / 10; } } for (const values of Object.values(awakening)) { assert.equal(values.length, 6); assert(Array.from(values).every(value => Number.isFinite(value) && value >= 0), 'Incomplete awakening rules'); } const musicRules = chapter.marathonMusicScoreBonuses; const challengeSongBonusCardIds = {}; for (const row of musicRules) { assert(songs.has(row.musicId) && cards.has(row.cardId)); assert(!row.characterId && !row.cardAttributeType && !row.cardRarity && !row.cardPotentialUpgradeCount); (challengeSongBonusCardIds[row.musicId] ??= []).push(row.cardId); } assert.equal(new Set(formation.map(row => row.characterId)).size, formation.length); assert.equal(new Set(featured.map(row => row.cardId)).size, featured.length); const poster = posters.find(row => row.id === `poster-${chapter.id}`); return { id: event.eventId, name: posterNames.get(poster?.nameLangId) ?? event.name, startsAt: iso(event.startTime), endsAt: iso(event.endTime), exchangeEndsAt: iso(event.exchangeEndTime), bonusCharacterIds: formation.map(row => row.characterId), featuredCardIds: featured.map(row => row.cardId), challengeSongBonusCardIds, formationBonusPercent: uniform(formation, 'marathonScoreQuantityUpPermilUp'), featuredCardBonusPercent: uniform(featured, 'marathonScoreQuantityUpPermilUp'), challengeSongScoreBonusPercent: uniform(musicRules, 'scoreUpPermilUp'), awakeningBonusPercentByRarity: awakening }; } const output = { source: { file: `data/game-events/${pointer.file}`, sha256: pointer.sha256, capturedAt: snapshot.source.capturedAt, masterDataVersion: db.masterDataVersion }, events: pointer.eventIds.map(id => project(snapshot.events.find(event => event.eventId === id))) }; const destination = new URL('../src/lib/data/events-api.json', import.meta.url); writeFileSync(destination, JSON.stringify(output, null, 2) + '\n'); console.log(`Generated ${output.events.length} event(s): ${fileURLToPath(destination)}`);