import { readdirSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; const classifications = { durableJournal: [ 'betting.bet', 'messages.delete', 'messages.respond', 'messages.send', 'nation.setBill', 'nation.setBlockScout', 'nation.setBlockWar', 'nation.setNotice', 'nation.setRate', 'nation.setScoutMsg', 'nation.setSecretLimit', 'npc.setGeneralPriority', 'npc.setNationPolicy', 'npc.setNationPriority', 'turns.repeatGeneral', 'turns.setGeneral', 'turns.setGeneralBulk', 'turns.shiftGeneral', 'vote.closePoll', 'vote.createPoll', 'vote.submitVote', 'vote.updatePoll', ], separateAccessJournal: ['public.recordAccess'], explicitNoRealtimeConsumer: [ 'board.writeArticle', 'board.writeComment', 'diplomacy.destroyLetter', 'diplomacy.respondLetter', 'diplomacy.rollbackLetter', 'diplomacy.sendLetter', 'inherit.checkOwner', 'join.getSelectionPool', 'join.listPossessCandidates', 'messages.readLatest', 'turns.repeatNation', 'turns.setNation', 'turns.setNationBulk', 'turns.shiftNation', 'vote.addComment', ], engineOwned: [ 'auction.bidBuyRice', 'auction.bidSellRice', 'auction.bidUnique', 'auction.openBuyRice', 'auction.openSellRice', 'auction.openUnique', 'general.adjustIcon', 'general.buildNationCandidate', 'general.dieOnPrestart', 'general.dropItem', 'general.ensureDieOnPrestartStatus', 'general.instantRetreat', 'general.setMySetting', 'general.vacation', 'inherit.openUniqueAuction', 'join.createGeneral', 'join.possessGeneral', 'join.reselectPoolGeneral', 'join.selectPoolGeneral', 'nation.appoint', 'nation.changePermission', 'nation.kick', 'troop.create', 'troop.exit', 'troop.join', 'troop.kick', 'troop.rename', ], mixedSaga: [ 'inherit.buyHiddenBuff', 'inherit.buyRandomUnique', 'inherit.resetSpecialWar', 'inherit.resetStat', 'inherit.resetTurnTime', 'inherit.setNextSpecialWar', 'tournament.cancel', 'tournament.join', 'tournament.placeBet', ], redisProjection: [ 'tournament.patchState', 'tournament.seedParticipants', 'tournament.setBettingEntries', 'tournament.setMatches', 'tournament.setParticipants', 'tournament.setState', ], operational: ['turnDaemon.pause', 'turnDaemon.resume', 'turnDaemon.run'], externalUpload: ['board.uploadImage'], readOnlyMutationTransport: ['battle.prepareSimulation', 'battle.simulate'], sessionOnly: ['auth.exchangeGatewayToken'], } as const; const routerRoot = fileURLToPath(new URL('../src/router/', import.meta.url)); const listTypeScriptFiles = (directory: string): string[] => readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const target = path.join(directory, entry.name); if (entry.isDirectory()) return listTypeScriptFiles(target); return entry.isFile() && entry.name.endsWith('.ts') ? [target] : []; }); const extractMutationNames = (file: string): string[] => { const source = readFileSync(file, 'utf8'); const names: string[] = []; for (const mutation of source.matchAll(/\.mutation\s*\(/gu)) { const prefix = source.slice(0, mutation.index); const propertyCandidates = [...prefix.matchAll(/^ {4,8}([A-Za-z][A-Za-z0-9]*):/gmu)]; const exportedCandidates = [...prefix.matchAll(/^export const ([A-Za-z][A-Za-z0-9]*)\s*=/gmu)]; const property = propertyCandidates.at(-1); const exported = exportedCandidates.at(-1); const propertyIndex = property?.index ?? -1; const exportedIndex = exported?.index ?? -1; const name = propertyIndex > exportedIndex ? property?.[1] : exported?.[1]; if (!name) throw new Error(`Could not resolve mutation name in ${file}`); names.push(name); } return names; }; const routePrefix = (file: string): string => { const relative = path.relative(routerRoot, file); const [top] = relative.split(path.sep); if (!top) throw new Error(`Could not resolve router prefix for ${file}`); return top.endsWith('.ts') ? path.basename(top, '.ts') : top; }; describe('game-api direct mutation journal inventory', () => { it('requires every router mutation to retain an explicit ownership and realtime classification', () => { const actual = listTypeScriptFiles(routerRoot) .flatMap((file) => extractMutationNames(file).map((name) => `${routePrefix(file)}.${name}`)) .sort(); const classified = Object.values(classifications).flat().sort(); expect(new Set(classified).size).toBe(classified.length); expect(classified).toHaveLength(87); expect(actual).toEqual(classified); }); });