diff --git a/app/game-api/src/router/archive/index.ts b/app/game-api/src/router/archive/index.ts index 34e465d..50bc33c 100644 --- a/app/game-api/src/router/archive/index.ts +++ b/app/game-api/src/router/archive/index.ts @@ -1,3 +1,6 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + import { asRecord } from '@sammo-ts/common'; import { readOnlyAuthedProcedure, router } from '../../trpc.js'; @@ -5,7 +8,40 @@ import { readOnlyAuthedProcedure, router } from '../../trpc.js'; const numberOrNull = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) ? value : null; -const textOrNull = (value: unknown): string | null => (typeof value === 'string' ? value : null); +const firstNumber = (record: Record, ...keys: string[]): number | null => { + for (const key of keys) { + const value = numberOrNull(record[key]); + if (value !== null) { + return value; + } + } + return null; +}; + +const displayTextOrNull = (value: unknown): string | null => { + if (typeof value === 'string') { + return value; + } + return typeof value === 'number' && Number.isFinite(value) ? String(value) : null; +}; + +const parseHistory = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0); + } + if (typeof value !== 'string') { + return []; + } + return value + .split(//i) + .map((entry) => entry.trim()) + .filter(Boolean); +}; + +const zPastPlayDetailInput = z.object({ + serverId: z.string().trim().min(1).max(64), + generalNo: z.number().int().positive(), +}); export const archiveRouter = router({ myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => { @@ -22,7 +58,7 @@ export const archiveRouter = router({ } const serverIds = [...new Set(generals.map((general) => general.serverId))]; - const [games, nations] = await Promise.all([ + const [games, nations, emperors] = await Promise.all([ ctx.db.gameHistory.findMany({ where: { serverId: { in: serverIds } }, }), @@ -30,9 +66,20 @@ export const archiveRouter = router({ where: { serverId: { in: serverIds } }, orderBy: [{ date: 'desc' }, { id: 'desc' }], }), + ctx.db.emperor.findMany({ + where: { serverId: { in: serverIds } }, + orderBy: { id: 'desc' }, + select: { id: true, serverId: true }, + }), ]); const gameByServer = new Map(games.map((game) => [game.serverId, game])); + const emperorByServer = new Map(); + for (const emperor of emperors) { + if (emperor.serverId && !emperorByServer.has(emperor.serverId)) { + emperorByServer.set(emperor.serverId, emperor.id); + } + } const nationByServerAndId = new Map(); for (const nation of nations) { const key = `${nation.serverId}:${nation.nation}`; @@ -49,6 +96,7 @@ export const archiveRouter = router({ season: number | null; scenario: number | null; scenarioName: string | null; + dynastyId: number | null; generals: Array<{ generalNo: number; name: string; @@ -65,6 +113,7 @@ export const archiveRouter = router({ personal: string | null; special: string | null; special2: string | null; + historyCount: number; }>; } >(); @@ -79,13 +128,16 @@ export const archiveRouter = router({ season: game?.season ?? null, scenario: game?.scenario ?? null, scenarioName: game?.scenarioName ?? null, + dynastyId: emperorByServer.get(general.serverId) ?? null, generals: [], }; seasons.set(general.serverId, season); } const data = asRecord(general.data); - const nationId = numberOrNull(data.nation) ?? 0; + const stats = asRecord(data.stats); + const role = asRecord(data.role); + const nationId = firstNumber(data, 'nationId', 'nation') ?? 0; const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`); const nationData = asRecord(nation?.data); season.generals.push({ @@ -93,17 +145,18 @@ export const archiveRouter = router({ name: general.name, lastYearMonth: general.lastYearMonth, nationId, - nationName: textOrNull(nationData.name) ?? (nationId === 0 ? '재야' : '미상'), - nationColor: textOrNull(nationData.color) ?? '#000000', - leadership: numberOrNull(data.leadership), - strength: numberOrNull(data.strength), - intel: numberOrNull(data.intel), + nationName: displayTextOrNull(nationData.name) ?? (nationId === 0 ? '재야' : '미상'), + nationColor: displayTextOrNull(nationData.color) ?? '#000000', + leadership: firstNumber(data, 'leadership', 'leader') ?? numberOrNull(stats.leadership), + strength: firstNumber(data, 'strength', 'power') ?? numberOrNull(stats.strength), + intel: firstNumber(data, 'intel', 'intelligence') ?? numberOrNull(stats.intelligence), experience: numberOrNull(data.experience), dedication: numberOrNull(data.dedication), - officerLevel: numberOrNull(data.officer_level), - personal: textOrNull(data.personal), - special: textOrNull(data.special), - special2: textOrNull(data.special2), + officerLevel: firstNumber(data, 'officerLevel', 'officer_level'), + personal: displayTextOrNull(data.personalCode ?? data.personal ?? role.personality), + special: displayTextOrNull(data.specialCode ?? data.special ?? role.specialDomestic), + special2: displayTextOrNull(data.special2Code ?? data.special2 ?? role.specialWar), + historyCount: parseHistory(data.history).length, }); } @@ -115,4 +168,28 @@ export const archiveRouter = router({ }), }; }), + myPastPlayDetail: readOnlyAuthedProcedure.input(zPastPlayDetailInput).query(async ({ ctx, input }) => { + const owner = ctx.auth?.user.id; + if (!owner) { + throw new Error('Authenticated archive query is missing its user identity'); + } + const general = await ctx.db.oldGeneral.findFirst({ + where: { + owner, + serverId: input.serverId, + generalNo: input.generalNo, + }, + }); + if (!general) { + throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' }); + } + const data = asRecord(general.data); + return { + serverId: general.serverId, + generalNo: general.generalNo, + name: general.name, + lastYearMonth: general.lastYearMonth, + history: parseHistory(data.history), + }; + }), }); diff --git a/app/game-api/test/archiveRouter.test.ts b/app/game-api/test/archiveRouter.test.ts index 8f65b93..a6fad45 100644 --- a/app/game-api/test/archiveRouter.test.ts +++ b/app/game-api/test/archiveRouter.test.ts @@ -41,14 +41,51 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => { turnTime: new Date('2025-01-01T00:00:00.000Z'), data: { nation: 3, - leadership: 80, - strength: 70, + leader: 80, + power: 70, intel: 60, - personal: 'che_의리', + officer_level: 8, + personal: 3, + history: '●첫 기록
●둘째 기록
', + }, + }, + { + id: 2, + serverId: 'che_legacy_1', + generalNo: 11, + owner: 'user-1', + name: '현재형장수', + lastYearMonth: 22012, + turnTime: new Date('2025-01-01T00:00:00.000Z'), + data: { + nationId: 3, + stats: { leadership: 81, strength: 71, intelligence: 61 }, + officerLevel: 7, + role: { + personality: 'che_의리', + specialDomestic: 'che_상재', + specialWar: 'che_신산', + }, + history: ['●현재형 기록'], }, }, ] : [], + findFirst: async ({ where }: { where: { owner: string; serverId: string; generalNo: number } }) => + where.owner === 'user-1' && where.serverId === 'che_legacy_1' && where.generalNo === 10 + ? { + id: 1, + serverId: 'che_legacy_1', + generalNo: 10, + owner: 'user-1', + name: '과거장수', + lastYearMonth: 22012, + turnTime: new Date('2025-01-01T00:00:00.000Z'), + data: { + history: '●첫 기록
●둘째 기록
', + }, + } + : null, }, gameHistory: { findMany: async () => [ @@ -77,6 +114,9 @@ const context = (session: GameSessionTokenPayload | null): GameApiContext => { }, ], }, + emperor: { + findMany: async () => [{ id: 7, serverId: 'che_legacy_1' }], + }, }; const redis = { get: async () => null, @@ -108,14 +148,56 @@ describe('archive.myPastPlays', () => { expect.objectContaining({ serverId: 'che_legacy_1', scenarioName: '테스트', + dynastyId: 7, generals: [ expect.objectContaining({ name: '과거장수', nationName: '촉', leadership: 80, + strength: 70, + officerLevel: 8, + personal: '3', + historyCount: 2, + }), + expect.objectContaining({ + name: '현재형장수', + nationName: '촉', + leadership: 81, + strength: 71, + intel: 61, + officerLevel: 7, + personal: 'che_의리', + special: 'che_상재', + special2: 'che_신산', + historyCount: 1, }), ], }), ]); }); + + it('loads archived history only for a general owned by the authenticated session', async () => { + const input = { serverId: 'che_legacy_1', generalNo: 10 }; + await expect(appRouter.createCaller(context(null)).archive.myPastPlayDetail(input)).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + + const result = await appRouter.createCaller(context(auth)).archive.myPastPlayDetail(input); + expect(result).toEqual({ + serverId: 'che_legacy_1', + generalNo: 10, + name: '과거장수', + lastYearMonth: 22012, + history: ['●첫 기록', '●둘째 기록'], + }); + + const otherUser = { + ...auth, + sessionId: 'other-session', + user: { ...auth.user, id: 'user-2', username: 'user-2' }, + }; + await expect(appRouter.createCaller(context(otherUser)).archive.myPastPlayDetail(input)).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); }); diff --git a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts index a58cfb5..53cbbe6 100644 --- a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts +++ b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts @@ -1,5 +1,6 @@ import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common'; import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; import type { GeneralLifecycleEvent } from './inMemoryWorld.js'; @@ -260,10 +261,20 @@ const archiveDeletedGeneral = async ( ): Promise => { const serverId = typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; + const history = await prisma.logEntry.findMany({ + where: { + generalId: event.generalId, + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + }, + orderBy: { id: 'desc' }, + select: { text: true }, + }); const data = { ...event.before, turnTime: event.before.turnTime.toISOString(), recentWarTime: event.before.recentWarTime?.toISOString() ?? null, + history: history.map((entry) => entry.text), }; await prisma.oldGeneral.upsert({ where: { by_no: { serverId, generalNo: event.generalId } }, diff --git a/app/game-engine/src/turn/unificationHandler.ts b/app/game-engine/src/turn/unificationHandler.ts index 490c7ad..a526ac5 100644 --- a/app/game-engine/src/turn/unificationHandler.ts +++ b/app/game-engine/src/turn/unificationHandler.ts @@ -651,6 +651,26 @@ export const createUnificationHandler = (options: { const oldGeneralTargets = generalRows.filter( (general) => general.nationId === 0 || general.nationId === winnerNationId ); + const generalHistoryRows = oldGeneralTargets.length + ? await prisma.logEntry.findMany({ + where: { + generalId: { in: oldGeneralTargets.map((general) => general.id) }, + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + }, + orderBy: { id: 'desc' }, + select: { generalId: true, text: true }, + }) + : []; + const historyByGeneral = new Map(); + for (const row of generalHistoryRows) { + if (row.generalId === null) { + continue; + } + const history = historyByGeneral.get(row.generalId) ?? []; + history.push(row.text); + historyByGeneral.set(row.generalId, history); + } await Promise.all( oldGeneralTargets.map((general) => ((snapshot) => @@ -680,6 +700,7 @@ export const createUnificationHandler = (options: { }))({ ...general, turnTime: general.turnTime.toISOString(), + history: historyByGeneral.get(general.id) ?? [], }) ) ); diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts index c9a3c4f..75d8dfb 100644 --- a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts +++ b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts @@ -1,5 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { asRecord } from '@sammo-ts/common'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js'; import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js'; @@ -72,6 +74,7 @@ integration('general turn lifecycle persistence', () => { let close: (() => Promise) | undefined; const cleanup = async () => { + await db.logEntry.deleteMany({ where: { generalId: { in: generalIds } } }); await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } }); await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } }); await db.oldGeneral.deleteMany({ where: { serverId, generalNo: { in: generalIds } } }); @@ -116,6 +119,26 @@ integration('general turn lifecycle persistence', () => { { generalId: general.id, nationId: 0, type: 'firenum', value: 1 }, ], }); + await db.logEntry.createMany({ + data: [ + { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + year: 199, + month: 12, + generalId: general.id, + text: '●첫 기록', + }, + { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + year: 200, + month: 1, + generalId: general.id, + text: '●둘째 기록', + }, + ], + }); await db.$transaction((tx) => persistGeneralLifecycleEvents( @@ -127,7 +150,10 @@ integration('general turn lifecycle persistence', () => { ); expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toBeNull(); - expect(await db.oldGeneral.findUnique({ where: { by_no: { serverId, generalNo: general.id } } })).not.toBeNull(); + const archived = await db.oldGeneral.findUniqueOrThrow({ + where: { by_no: { serverId, generalNo: general.id } }, + }); + expect(asRecord(archived.data).history).toEqual(['●둘째 기록', '●첫 기록']); expect( await db.inheritancePoint.findUnique({ where: { userId_key: { userId: general.userId!, key: 'previous' } }, diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.test.ts new file mode 100644 index 0000000..a88bb46 --- /dev/null +++ b/app/game-engine/test/generalTurnLifecyclePersistence.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GamePrisma } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; + +import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js'; +import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral } from '../src/turn/types.js'; + +const archivedGeneral = (): TurnGeneral => ({ + id: 91, + userId: null, + name: '기록장수', + nationId: 0, + cityId: 1, + troopId: 0, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + experience: 1_000, + dedication: 500, + officerLevel: 0, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 1, + train: 0, + atmos: 0, + age: 70, + npcState: 2, + bornYear: 170, + deadYear: 240, + affinity: 50, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 0 }, + turnTime: new Date('0200-01-01T00:00:00.000Z'), +}); + +describe('general lifecycle archive history', () => { + it('stores the general history in descending log order when a general is deleted', async () => { + const general = archivedGeneral(); + const upsert = vi.fn(async () => undefined); + const prisma = { + generalAccessLog: { + updateMany: vi.fn(async () => ({ count: 1 })), + deleteMany: vi.fn(async () => ({ count: 1 })), + }, + logEntry: { + findMany: vi.fn(async () => [{ text: '●둘째 기록' }, { text: '●첫 기록' }]), + }, + oldGeneral: { upsert }, + } as unknown as GamePrisma.TransactionClient; + const event: GeneralLifecycleEvent = { + generalId: general.id, + outcome: 'deleted', + before: general, + year: 200, + month: 1, + }; + + await persistGeneralLifecycleEvents(prisma, [event], { serverId: 'archive-fixture' }, {}); + + expect(prisma.logEntry.findMany).toHaveBeenCalledWith({ + where: { + generalId: general.id, + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + }, + orderBy: { id: 'desc' }, + select: { text: true }, + }); + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + data: expect.objectContaining({ + history: ['●둘째 기록', '●첫 기록'], + }), + }), + }) + ); + }); +}); diff --git a/app/game-frontend/e2e/pastPlays.spec.ts b/app/game-frontend/e2e/pastPlays.spec.ts index e75172d..4b6abbd 100644 --- a/app/game-frontend/e2e/pastPlays.spec.ts +++ b/app/game-frontend/e2e/pastPlays.spec.ts @@ -24,6 +24,7 @@ const installArchive = async (page: Page) => { season: 51, scenario: 2, scenarioName: '천하쟁패', + dynastyId: 7, generals: [ { generalNo: 17, @@ -41,12 +42,22 @@ const installArchive = async (page: Page) => { personal: '대담', special: '상재', special2: '신산', + historyCount: 2, }, ], }, ], }); } + if (operation === 'archive.myPastPlayDetail') { + return response({ + serverId: 'che_2024_01', + generalNo: 17, + name: '관우', + lastYearMonth: 21403, + history: ['●214년 3월: 촉에 임관', '●214년 1월: 성도에서 거병'], + }); + } return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); @@ -65,6 +76,12 @@ test('past plays is available without a current general and preserves desktop in await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible(); await expect(page.getByText('천하쟁패 · 51기')).toBeVisible(); await expect(page.locator('.general-name')).toHaveText('관우'); + await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute('href', '/che/dynasty/7'); + const historyToggle = page.locator('.history-toggle'); + await expect(historyToggle).toHaveText('보기 (2)'); + await historyToggle.click(); + await expect(page.getByText('214년 3월: 촉에 임관')).toBeVisible(); + await expect(historyToggle).toHaveAttribute('aria-expanded', 'true'); const geometry = await root.evaluate((element) => { const rect = element.getBoundingClientRect(); @@ -117,6 +134,6 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport', overflowX: getComputedStyle(element).overflowX, })); // The shared legacy shell keeps its historical 500 px minimum canvas. - expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 820, overflowX: 'auto' }); + expect(metrics).toEqual({ clientWidth: 500, scrollWidth: 940, overflowX: 'auto' }); await expect(page.locator('.title-row')).toHaveCSS('flex-direction', 'column'); }); diff --git a/app/game-frontend/src/views/PastPlaysView.vue b/app/game-frontend/src/views/PastPlaysView.vue index 57f22a6..b7be3e3 100644 --- a/app/game-frontend/src/views/PastPlaysView.vue +++ b/app/game-frontend/src/views/PastPlaysView.vue @@ -4,10 +4,18 @@ import { onMounted, ref } from 'vue'; import { trpc } from '../utils/trpc'; type Archive = Awaited>; +type PastPlayDetail = Awaited>; +type DetailState = { + open: boolean; + loading: boolean; + error: string | null; + detail: PastPlayDetail | null; +}; const archive = ref(null); const loading = ref(false); const error = ref(null); +const details = ref>({}); const loadArchive = async () => { if (loading.value) return; @@ -24,6 +32,43 @@ const loadArchive = async () => { const yearMonth = (value: number): string => `${Math.floor(value / 100)}년 ${value % 100}월`; const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value)); +const plainLog = (value: string): string => value.replace(/<[^>]+>/g, ''); +const detailKey = (serverId: string, generalNo: number): string => `${serverId}:${generalNo}`; + +const toggleHistory = async (serverId: string, generalNo: number): Promise => { + const key = detailKey(serverId, generalNo); + const current = details.value[key]; + if (current?.open) { + details.value = { ...details.value, [key]: { ...current, open: false } }; + return; + } + if (current?.detail) { + details.value = { ...details.value, [key]: { ...current, open: true } }; + return; + } + + details.value = { + ...details.value, + [key]: { open: true, loading: true, error: null, detail: null }, + }; + try { + const detail = await trpc.archive.myPastPlayDetail.query({ serverId, generalNo }); + details.value = { + ...details.value, + [key]: { open: true, loading: false, error: null, detail }, + }; + } catch (cause) { + details.value = { + ...details.value, + [key]: { + open: true, + loading: false, + error: cause instanceof Error ? cause.message : '장수 열전을 불러오지 못했습니다.', + detail: null, + }, + }; + } +}; onMounted(() => { void loadArchive(); @@ -48,10 +93,19 @@ onMounted(() => {
{{ season.serverId }} - - {{ season.scenarioName ?? '시나리오 미상' }} - - +
+ + {{ season.scenarioName ?? '시나리오 미상' }} + + + + 이 기수 국가 정보 + +
@@ -67,28 +121,82 @@ onMounted(() => { + - - - - - - - - - - - - +
성격 내정 특기 전투 특기장수 열전
{{ general.name }} - - {{ general.nationName }} - - {{ yearMonth(general.lastYearMonth) }}{{ valueOrDash(general.leadership) }}{{ valueOrDash(general.strength) }}{{ valueOrDash(general.intel) }}{{ valueOrDash(general.officerLevel) }}{{ valueOrDash(general.personal) }}{{ valueOrDash(general.special) }}{{ valueOrDash(general.special2) }}
@@ -190,13 +298,24 @@ onMounted(() => { color: #bbb; } +.season-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.nation-archive-link { + min-height: 24px; + padding-block: 2px; +} + .table-scroll { overflow-x: auto; } table { width: 100%; - min-width: 820px; + min-width: 940px; border-collapse: collapse; table-layout: auto; background: #191919; @@ -229,11 +348,45 @@ th { text-shadow: 0 1px 1px #000; } +.history-toggle { + min-height: 24px; + padding-block: 2px; +} + +.history-row td { + padding: 10px 12px; + text-align: left; + white-space: normal; + background: #101010; +} + +.history-row p { + margin: 0; + color: #bbb; +} + +.history-error { + color: #ff8d8d !important; +} + +.history-list { + display: grid; + gap: 5px; + margin: 0; + padding-left: 26px; + color: #ddd; +} + @media (max-width: 640px) { .title-row, .season-heading { align-items: flex-start; flex-direction: column; } + + .season-actions { + align-items: flex-start; + flex-direction: column; + } } diff --git a/docs/legacy-db-migration.md b/docs/legacy-db-migration.md index 3cc072e..2ff4b53 100644 --- a/docs/legacy-db-migration.md +++ b/docs/legacy-db-migration.md @@ -77,6 +77,26 @@ excluded. In particular, `general`, `city`, `nation`, their turn queues, `ng_betting`, `reserved_open`, `select_pool`, `select_npc_token` and `plock` must not be used to reconstruct a running season. +## Archived play read model + +`/past-plays` is an authenticated, read-only projection. The server derives the +archive owner from the game session and never accepts an owner ID from the +browser. + +- `archive.myPastPlays` combines the owner's `ng_old_generals` rows with + `ng_games`, the latest matching `ng_old_nations` snapshot and an optional + `emperior` row. It returns summary fields and a link target for the existing + public dynasty/nation detail. +- `archive.myPastPlayDetail(serverId, generalNo)` includes the session owner in + the database predicate before returning `data.history`. A foreign or missing + record uses the same not-found response. +- Legacy `data.history` may be either an array or a `
`-joined string. The API + normalizes both to a newest-first string array, and the frontend renders plain + text rather than archived markup. +- Runtime death and unification archival writes the current general + `GENERAL/HISTORY` rows into the same `data.history` field, so newly completed + seasons remain compatible with imported rows. + ## Cutover procedure 1. Keep the original compressed dumps immutable and restore each source to a @@ -90,8 +110,8 @@ must not be used to reconstruct a running season. run the same commands with `--apply`. 6. Repeat each apply. Counts must remain unchanged. 7. Verify Kakao migration timestamps, password-hash shapes, archive ownership, - old-nation/history duplicate preservation and the `/past-plays` authenticated - read path. + old-nation/history duplicate preservation, `/past-plays` list/detail access, + foreign-owner denial and the dynasty link. 8. Retain the MariaDB dumps as rollback evidence. Rollback restores the pre-cutover PostgreSQL backup; it does not reverse individual importer upserts.