import { constants, publicEncrypt } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { expect, test } from '@playwright/test'; import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api'; import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api'; import { Pool } from 'pg'; const gatewayUrl = process.env.SAMMO_LIFECYCLE_GATEWAY_URL ?? 'http://127.0.0.1:15001/gateway/api/trpc'; const gameUrl = process.env.SAMMO_LIFECYCLE_GAME_URL ?? 'http://127.0.0.1:15015/hwe/api/trpc'; const profile = process.env.SAMMO_LIFECYCLE_PROFILE_KEY?.trim() || 'hwe:default'; const sampleImage = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64' ); const requiredEnv = (name: string): string => { const value = process.env[name]?.trim(); if (!value) throw new Error(`${name} is required`); return value; }; const readPassword = async (account: 'admin' | 'user_a' | 'user_b'): Promise => (await readFile(`${requiredEnv('SAMMO_LIFECYCLE_SECRET_ROOT')}/${account}_password`, 'utf8')).trim(); const createGatewayClient = (session: { token?: string }) => createTRPCProxyClient({ links: [ httpBatchLink({ url: gatewayUrl, headers: () => (session.token ? { 'x-session-token': session.token } : {}), }), ], }); const createGameClient = (session: { token?: string }) => createTRPCProxyClient({ links: [ httpBatchLink({ url: gameUrl, headers: () => (session.token ? { authorization: `Bearer ${session.token}` } : {}), }), ], }); const loginGame = async (username: string, password: string) => { const gatewaySession: { token?: string } = {}; const gateway = createGatewayClient(gatewaySession); const passwordKey = await gateway.auth.passwordKey.query(); const credential = { keyId: passwordKey.keyId, ciphertext: publicEncrypt( { key: passwordKey.publicKeyPem, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256', }, Buffer.from(password, 'utf8') ).toString('base64'), }; const login = await gateway.auth.login.mutate({ username, credential }); if (login.status === 'otp') { throw new Error('Live lifecycle fixture requires a currently verified KakaoTalk login.'); } gatewaySession.token = login.sessionToken; const issued = await gateway.auth.issueGameSession.mutate({ sessionToken: login.sessionToken, profile, }); const gameSession: { token?: string } = {}; const game = createGameClient(gameSession); gameSession.token = (await game.auth.exchangeGatewayToken.mutate({ gatewayToken: issued.gameToken })).accessToken; return { gateway, game }; }; const monthOrdinal = (year: number, month: number): number => year * 12 + month - 1; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const createDatabasePool = (): Pool => new Pool({ host: process.env.POSTGRES_HOST ?? '127.0.0.1', port: Number(process.env.POSTGRES_PORT ?? 5432), database: process.env.POSTGRES_DB ?? 'sammo', user: process.env.POSTGRES_USER ?? 'sammo', password: requiredEnv('POSTGRES_PASSWORD'), options: '-c search_path=hwe', }); test('two users enlist, the engine advances three months, and only a chief uploads a nation image', async () => { test.setTimeout(300_000); const adminUsername = requiredEnv('SAMMO_LIFECYCLE_ADMIN_USERNAME'); const [{ game: admin }, { game: userA }, { game: userB }] = await Promise.all([ loginGame(adminUsername, await readPassword('admin')), loginGame('guiusera', await readPassword('user_a')), loginGame('guiuserb', await readPassword('user_b')), ]); const db = createDatabasePool(); try { const initialWorldResult = await db.query<{ current_year: number; current_month: number }>( 'SELECT current_year, current_month FROM world_state LIMIT 1' ); const initialWorld = initialWorldResult.rows[0]; if (!initialWorld) throw new Error('HWE world_state is missing.'); const initialOrdinal = monthOrdinal(initialWorld.current_year, initialWorld.current_month); const reserveEnlist = async (game: typeof userA, destNationId: number) => { const context = await game.general.me.query(); if (!context?.general) throw new Error('User general is missing.'); if (context.general.nationId > 0) return context.general.id; const snapshot = await game.turns.reserved.getGeneral.query({ generalId: context.general.id }); await game.turns.reserved.setGeneral.mutate({ generalId: context.general.id, turnIndex: 0, action: 'che_임관', args: { destNationId }, expectedRevision: snapshot.revision, }); return context.general.id; }; const [generalAId, generalBId] = await Promise.all([reserveEnlist(userA, 1), reserveEnlist(userB, 1)]); const runNextMonth = async () => { const before = await admin.turnDaemon.status.query({ timeoutMs: 5_000 }); if (!before?.lastTurnTime) throw new Error('Turn daemon did not report the last monthly boundary.'); const beforeWorldResult = await db.query<{ current_year: number; current_month: number }>( 'SELECT current_year, current_month FROM world_state LIMIT 1' ); const beforeWorld = beforeWorldResult.rows[0]; if (!beforeWorld) throw new Error('HWE world_state is missing before turn execution.'); const expectedOrdinal = monthOrdinal(beforeWorld.current_year, beforeWorld.current_month) + 1; const lastBoundary = new Date(before.lastTurnTime).getTime(); const targetTime = new Date(Math.floor(lastBoundary / 3_600_000) * 3_600_000 + 3_900_000).toISOString(); await admin.turnDaemon.run.mutate({ reason: 'manual', targetTime, budget: { budgetMs: 90_000, maxGenerals: 10_000, catchUpCap: 1 }, }); const deadline = Date.now() + 90_000; while (Date.now() < deadline) { const after = await admin.turnDaemon.status.query({ timeoutMs: 5_000 }); if (after?.lastError) throw new Error(`Turn daemon failed: ${after.lastError}`); const worldResult = await db.query<{ current_year: number; current_month: number }>( 'SELECT current_year, current_month FROM world_state LIMIT 1' ); const world = worldResult.rows[0]; if (world && monthOrdinal(world.current_year, world.current_month) >= expectedOrdinal) { return; } await sleep(250); } throw new Error('Turn daemon did not complete the requested monthly boundary.'); }; for (let index = 0; index < 3; index += 1) { await runNextMonth(); } const progressedWorldResult = await db.query<{ current_year: number; current_month: number }>( 'SELECT current_year, current_month FROM world_state LIMIT 1' ); const progressedWorld = progressedWorldResult.rows[0]; if (!progressedWorld) throw new Error('HWE world_state disappeared.'); expect(monthOrdinal(progressedWorld.current_year, progressedWorld.current_month) - initialOrdinal).toBe(3); let enlisted = await db.query<{ id: number; nation_id: number; officer_level: number }>( 'SELECT id, nation_id, officer_level FROM general WHERE id = ANY($1::int[]) ORDER BY id', [[generalAId, generalBId]] ); expect(enlisted.rows).toHaveLength(2); if (enlisted.rows.some((general) => general.nation_id === 0)) { const candidateResult = await db.query<{ id: number }>( `SELECT n.id FROM nation n LEFT JOIN general g ON g.nation_id = n.id WHERE n.id > 0 GROUP BY n.id, n.level HAVING COUNT(g.id) < 10 ORDER BY n.level DESC, n.id LIMIT 1` ); const candidate = candidateResult.rows[0]; if (!candidate) throw new Error('No nation has room for an opening-period enlistment.'); if (enlisted.rows.find((general) => general.id === generalAId)?.nation_id === 0) { await reserveEnlist(userA, candidate.id); } if (enlisted.rows.find((general) => general.id === generalBId)?.nation_id === 0) { await reserveEnlist(userB, candidate.id); } await runNextMonth(); enlisted = await db.query<{ id: number; nation_id: number; officer_level: number }>( 'SELECT id, nation_id, officer_level FROM general WHERE id = ANY($1::int[]) ORDER BY id', [[generalAId, generalBId]] ); } expect(enlisted.rows.every((general) => general.nation_id > 0)).toBe(true); const waitForPaused = async (paused: boolean) => { const deadline = Date.now() + 15_000; while (Date.now() < deadline) { const status = await admin.turnDaemon.status.query({ timeoutMs: 5_000 }); if (status?.lastError) throw new Error(`Turn daemon failed: ${status.lastError}`); if (status?.paused === paused) return; await sleep(200); } throw new Error(`Turn daemon did not become ${paused ? 'paused' : 'resumed'}.`); }; const setOfficerLevels = async (aLevel: number, bLevel: number) => { const connection = await db.connect(); try { await connection.query('BEGIN'); await connection.query('UPDATE general SET officer_level = $2 WHERE id = $1', [generalAId, aLevel]); await connection.query('UPDATE general SET officer_level = $2 WHERE id = $1', [generalBId, bLevel]); await connection.query('COMMIT'); } catch (error) { await connection.query('ROLLBACK'); throw error; } finally { connection.release(); } }; // Appointment is fixture setup for the authorization boundary below. Enlistment and // month advancement above are performed exclusively by the real turn daemon. await admin.turnDaemon.pause.mutate({ reason: 'chief upload authorization fixture' }); await waitForPaused(true); try { await setOfficerLevels(5, 1); await expect(userB.board.uploadImage.mutate({ dataUrl: sampleImage.toString('base64') })).rejects.toThrow( '권한이 부족합니다. 수뇌부가 아닙니다.' ); const uploaded = await userA.board.uploadImage.mutate({ dataUrl: sampleImage.toString('base64') }); expect(uploaded.url).toMatch(/^https:\/\/sam-image\.hided\.net\/uploads\/core2026\/[a-f0-9]{32}\.webp$/); expect(uploaded.format).toBe('webp'); expect(uploaded.width).toBe(1); expect(uploaded.height).toBe(1); expect(uploaded.size).toBeGreaterThan(0); } finally { await setOfficerLevels(1, 1); await admin.turnDaemon.resume.mutate({ reason: 'chief upload authorization fixture complete' }); await waitForPaused(false); } } finally { await db.end(); } });