feat: expose archived general histories

This commit is contained in:
2026-07-29 00:05:18 +00:00
parent 1181f6f4e0
commit d621023508
9 changed files with 537 additions and 43 deletions
+89 -12
View File
@@ -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<string, unknown>, ...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(/<br\s*\/?>/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<string, number>();
for (const emperor of emperors) {
if (emperor.serverId && !emperorByServer.has(emperor.serverId)) {
emperorByServer.set(emperor.serverId, emperor.id);
}
}
const nationByServerAndId = new Map<string, (typeof nations)[number]>();
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),
};
}),
});
+85 -3
View File
@@ -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: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
},
},
{
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: ['<C>●</>현재형 기록'],
},
},
]
: [],
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: '<C>●</>첫 기록<br><Y>●</>둘째 기록<br>',
},
}
: 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: ['<C>●</>첫 기록', '<Y>●</>둘째 기록'],
});
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',
});
});
});
@@ -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<void> => {
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 } },
@@ -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<number, string[]>();
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) ?? [],
})
)
);
@@ -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<void>) | 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: '<C>●</>첫 기록',
},
{
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
year: 200,
month: 1,
generalId: general.id,
text: '<Y>●</>둘째 기록',
},
],
});
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(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
expect(
await db.inheritancePoint.findUnique({
where: { userId_key: { userId: general.userId!, key: 'previous' } },
@@ -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: '<Y>●</>둘째 기록' }, { text: '<C>●</>첫 기록' }]),
},
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: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
}),
}),
})
);
});
});
+18 -1
View File
@@ -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: ['<C>●</>214년 3월: 촉에 임관', '<Y>●</>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');
});
+177 -24
View File
@@ -4,10 +4,18 @@ import { onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type Archive = Awaited<ReturnType<typeof trpc.archive.myPastPlays.query>>;
type PastPlayDetail = Awaited<ReturnType<typeof trpc.archive.myPastPlayDetail.query>>;
type DetailState = {
open: boolean;
loading: boolean;
error: string | null;
detail: PastPlayDetail | null;
};
const archive = ref<Archive | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const details = ref<Record<string, DetailState>>({});
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<void> => {
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(() => {
<section v-for="season in archive?.seasons ?? []" :key="season.serverId" class="season-card">
<div class="season-heading legacy-bg2">
<strong>{{ season.serverId }}</strong>
<span>
{{ season.scenarioName ?? '시나리오 미상' }}
<template v-if="season.season !== null"> · {{ season.season }}</template>
</span>
<div class="season-actions">
<span>
{{ season.scenarioName ?? '시나리오 미상' }}
<template v-if="season.season !== null"> · {{ season.season }}</template>
</span>
<RouterLink
v-if="season.dynastyId !== null"
class="legacy-button nation-archive-link"
:to="`/dynasty/${season.dynastyId}`"
>
기수 국가 정보
</RouterLink>
</div>
</div>
<div class="table-scroll">
<table>
@@ -67,28 +121,82 @@ onMounted(() => {
<th>성격</th>
<th>내정 특기</th>
<th>전투 특기</th>
<th>장수 열전</th>
</tr>
</thead>
<tbody>
<tr v-for="general in season.generals" :key="general.generalNo">
<td class="general-name">{{ general.name }}</td>
<td>
<span
class="nation-name"
:style="{ backgroundColor: general.nationColor, color: '#ffffff' }"
>
{{ general.nationName }}
</span>
</td>
<td>{{ yearMonth(general.lastYearMonth) }}</td>
<td>{{ valueOrDash(general.leadership) }}</td>
<td>{{ valueOrDash(general.strength) }}</td>
<td>{{ valueOrDash(general.intel) }}</td>
<td>{{ valueOrDash(general.officerLevel) }}</td>
<td>{{ valueOrDash(general.personal) }}</td>
<td>{{ valueOrDash(general.special) }}</td>
<td>{{ valueOrDash(general.special2) }}</td>
</tr>
<template v-for="general in season.generals" :key="general.generalNo">
<tr>
<td class="general-name">{{ general.name }}</td>
<td>
<span
class="nation-name"
:style="{ backgroundColor: general.nationColor, color: '#ffffff' }"
>
{{ general.nationName }}
</span>
</td>
<td>{{ yearMonth(general.lastYearMonth) }}</td>
<td>{{ valueOrDash(general.leadership) }}</td>
<td>{{ valueOrDash(general.strength) }}</td>
<td>{{ valueOrDash(general.intel) }}</td>
<td>{{ valueOrDash(general.officerLevel) }}</td>
<td>{{ valueOrDash(general.personal) }}</td>
<td>{{ valueOrDash(general.special) }}</td>
<td>{{ valueOrDash(general.special2) }}</td>
<td>
<button
class="legacy-button history-toggle"
type="button"
:aria-expanded="
details[detailKey(season.serverId, general.generalNo)]?.open ?? false
"
@click="toggleHistory(season.serverId, general.generalNo)"
>
{{
details[detailKey(season.serverId, general.generalNo)]?.open
? '접기'
: `보기 (${general.historyCount})`
}}
</button>
</td>
</tr>
<tr v-if="details[detailKey(season.serverId, general.generalNo)]?.open" class="history-row">
<td colspan="11">
<p
v-if="details[detailKey(season.serverId, general.generalNo)]?.loading"
role="status"
>
장수 열전을 불러오는 ...
</p>
<p
v-else-if="details[detailKey(season.serverId, general.generalNo)]?.error"
class="history-error"
role="alert"
>
{{ details[detailKey(season.serverId, general.generalNo)]?.error }}
</p>
<p
v-else-if="
details[detailKey(season.serverId, general.generalNo)]?.detail?.history
.length === 0
"
>
보관된 장수 열전이 없습니다.
</p>
<ol v-else class="history-list">
<li
v-for="(entry, index) in details[
detailKey(season.serverId, general.generalNo)
]?.detail?.history ?? []"
:key="index"
>
{{ plainLog(entry) }}
</li>
</ol>
</td>
</tr>
</template>
</tbody>
</table>
</div>
@@ -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;
}
}
</style>
+22 -2
View File
@@ -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 `<br>`-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.