Complete dynasty list legacy parity

This commit is contained in:
2026-07-26 05:33:42 +00:00
parent b02ab9fe99
commit 195af5580e
12 changed files with 1555 additions and 325 deletions
+68 -13
View File
@@ -2,22 +2,62 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { router, procedure } from '../../trpc.js';
import { procedure, router } from '../../trpc.js';
const zDynastyDetailInput = z.object({
emperorId: z.number().int().positive(),
});
const parseNumberArray = (value: unknown): number[] =>
Array.isArray(value) ? value.filter((item): item is number => typeof item === 'number') : [];
Array.isArray(value)
? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
: [];
const parseTextArray = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
const parseDisplayArray = (value: unknown): Array<string | number> =>
Array.isArray(value)
? value.filter(
(item): item is string | number =>
typeof item === 'string' || (typeof item === 'number' && Number.isFinite(item))
)
: [];
const formatNationType = (typeCode: string): string => {
const separator = typeCode.indexOf('_');
return separator < 0 ? typeCode : typeCode.slice(separator + 1);
};
const formatNationLevel = (level: number | null): string => {
if (level === null) {
return '';
}
return ['방랑군', '호족', '군벌', '주자사', '주목', '공', '왕', '황제'][level] ?? String(level);
};
export const dynastyRouter = router({
getList: procedure.query(async ({ ctx }) => {
const rows = await ctx.db.emperor.findMany({
orderBy: { id: 'desc' },
});
const [worldState, rows] = await Promise.all([
ctx.db.worldState.findFirst({
select: {
currentYear: true,
currentMonth: true,
},
}),
ctx.db.emperor.findMany({
orderBy: { id: 'desc' },
}),
]);
return {
current: worldState
? {
year: worldState.currentYear,
month: worldState.currentMonth,
}
: null,
entries: rows.map((row) => ({
id: row.id,
serverId: row.serverId ?? '',
@@ -30,11 +70,19 @@ export const dynastyRouter = router({
power: row.power ?? 0,
gennum: row.gennum ?? 0,
citynum: row.citynum ?? 0,
l12name: row.l12name ?? '',
l11name: row.l11name ?? '',
l10name: row.l10name ?? '',
l9name: row.l9name ?? '',
l8name: row.l8name ?? '',
l7name: row.l7name ?? '',
l6name: row.l6name ?? '',
l5name: row.l5name ?? '',
})),
};
}),
getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => {
const emperor = await ctx.db.emperor.findFirst({
const emperor = await ctx.db.emperor.findUnique({
where: { id: input.emperorId },
});
if (!emperor) {
@@ -43,7 +91,6 @@ export const dynastyRouter = router({
const aux = asRecord(emperor.aux);
const winnerNationId = typeof aux.winnerNationId === 'number' ? aux.winnerNationId : null;
const serverId = emperor.serverId ?? '';
const oldNationRows = await ctx.db.oldNation.findMany({
where: { serverId },
@@ -54,19 +101,26 @@ export const dynastyRouter = router({
.map((row) => {
const data = asRecord(row.data);
const nationId = row.nation ?? (typeof data.nation === 'number' ? data.nation : 0);
const typeCode = typeof data.type === 'string' ? data.type : '';
return {
nation: nationId,
isWinner: winnerNationId !== null && nationId === winnerNationId,
name: typeof data.name === 'string' ? data.name : row.nation === 0 ? '재야' : '미상',
name: typeof data.name === 'string' ? data.name : nationId === 0 ? '재야' : '미상',
color: typeof data.color === 'string' ? data.color : '#000000',
type: typeof data.type === 'string' ? data.type : '',
type: typeCode,
typeName: formatNationType(typeCode),
level: typeof data.level === 'number' ? data.level : null,
tech: typeof data.tech === 'number' ? data.tech : null,
maxPower: typeof data.maxPower === 'number' ? data.maxPower : null,
maxPower:
typeof data.maxPower === 'number'
? data.maxPower
: typeof data.power === 'number'
? data.power
: null,
maxCrew: typeof data.maxCrew === 'number' ? data.maxCrew : null,
maxCities: Array.isArray(data.maxCities) ? data.maxCities : [],
maxCities: parseDisplayArray(data.maxCities),
generals: parseNumberArray(data.generals),
history: Array.isArray(data.history) ? data.history : [],
history: parseTextArray(data.history),
date: row.date.toISOString(),
};
})
@@ -89,6 +143,7 @@ export const dynastyRouter = router({
const nations = nationEntries.map((entry) => ({
...entry,
levelName: formatNationLevel(entry.level),
generalsFull: entry.generals.map((id) => ({
generalNo: id,
name: generalMap.get(id)?.name ?? `#${id}`,
@@ -131,7 +186,7 @@ export const dynastyRouter = router({
tiger: emperor.tiger ?? '',
eagle: emperor.eagle ?? '',
gen: emperor.gen ?? '',
history: Array.isArray(emperor.history) ? emperor.history : [],
history: parseTextArray(emperor.history),
},
nations,
};
+65 -30
View File
@@ -7,7 +7,8 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
import type { GameApiContext } from '../../context.js';
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
import { procedure, router } from '../../trpc.js';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
type YearbookNation = {
id: number;
@@ -19,6 +20,7 @@ type YearbookNation = {
};
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
const zServerId = z.string().trim().min(1).max(64);
const computeHash = (payload: unknown): string =>
createHash('sha256').update(JSON.stringify(payload)).digest('hex');
@@ -189,53 +191,86 @@ const buildLogs = async (ctx: GameApiContext, year: number, month: number) => {
};
export const yearbookRouter = router({
getRange: procedure.query(async ({ ctx }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
getRange: authedProcedure
.input(
z
.object({
serverID: zServerId.optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
await getMyGeneral(ctx);
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
const targetProfileName = input?.serverID ?? ctx.profile.name;
const isCurrentProfile = targetProfileName === ctx.profile.name;
const firstRow = await ctx.db.yearbookHistory.findFirst({
where: { profileName: ctx.profile.name },
select: { year: true, month: true },
orderBy: [{ year: 'asc' }, { month: 'asc' }],
});
const firstRow = await ctx.db.yearbookHistory.findFirst({
where: { profileName: targetProfileName },
select: { year: true, month: true },
orderBy: [{ year: 'asc' }, { month: 'asc' }],
});
const lastRow = await ctx.db.yearbookHistory.findFirst({
where: { profileName: ctx.profile.name },
select: { year: true, month: true },
orderBy: [{ year: 'desc' }, { month: 'desc' }],
});
const lastRow = await ctx.db.yearbookHistory.findFirst({
where: { profileName: targetProfileName },
select: { year: true, month: true },
orderBy: [{ year: 'desc' }, { month: 'desc' }],
});
const currentYearMonth = joinYearMonth(worldState.currentYear, worldState.currentMonth);
const fallbackYearMonth = currentYearMonth - 1;
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
if (!isCurrentProfile && (!firstRow || !lastRow)) {
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 범위를 찾을 수 없습니다.' });
}
return {
firstYearMonth,
lastYearMonth,
currentYearMonth,
};
}),
getHistory: procedure
const currentYearMonth = joinYearMonth(worldState.currentYear, worldState.currentMonth);
const fallbackYearMonth = currentYearMonth - 1;
const firstYearMonth = firstRow
? joinYearMonth(firstRow.year, firstRow.month)
: fallbackYearMonth;
const lastYearMonth = lastRow
? joinYearMonth(lastRow.year, lastRow.month)
: fallbackYearMonth;
const selectedYearMonth = isCurrentProfile ? currentYearMonth : lastYearMonth;
return {
firstYearMonth,
lastYearMonth,
currentYearMonth: selectedYearMonth,
};
}),
getHistory: authedProcedure
.input(
z.object({
year: z.number().int(),
month: z.number().int().min(1).max(12),
hash: z.string().optional(),
serverID: zServerId.optional(),
})
)
.query(async ({ ctx, input }) => {
await getMyGeneral(ctx);
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
}
const targetProfileName = input.serverID ?? ctx.profile.name;
const isCurrentProfile = targetProfileName === ctx.profile.name;
const isCurrent =
isCurrentProfile &&
worldState.currentYear === input.year && worldState.currentMonth === input.month;
const { globalHistory, globalAction } = await buildLogs(ctx, input.year, input.month);
const { globalHistory, globalAction } = isCurrentProfile
? await buildLogs(ctx, input.year, input.month)
: {
globalHistory: [`<C>●</>${input.month}월: 기록 없음`],
globalAction: [`<C>●</>${input.month}월: 기록 없음`],
};
if (isCurrent) {
const map = await loadPublicMap(ctx, false);
@@ -260,7 +295,7 @@ export const yearbookRouter = router({
const row = await ctx.db.yearbookHistory.findFirst({
where: {
profileName: ctx.profile.name,
profileName: targetProfileName,
year: input.year,
month: input.month,
},
@@ -285,4 +320,4 @@ export const yearbookRouter = router({
}
return { notModified: false, hash, data };
}),
});
});
+234
View File
@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js';
const profile: GameProfile = {
id: 'che',
scenario: 'default',
name: 'che:default',
};
const emperor = {
id: 7,
serverId: 'hwe_260725_fixture',
phase: '훼2기',
nationCount: '3 / 8',
nationName: '촉, 위, 오',
nationHist: '병가(2), 유가(1)',
genCount: '7 / 21',
personalHist: '의리(4)',
specialHist: '상재(2)',
name: '촉',
type: 'che_병가',
color: '#FF0000',
year: 215,
month: 4,
power: 34434,
gennum: 7,
citynum: 8,
pop: '12345 / 15000',
poprate: '82.3 %',
gold: 50000,
rice: 60000,
l12name: '유비',
l12pic: '',
l11name: '제갈량',
l11pic: '',
l10name: '관우',
l10pic: '',
l9name: '방통',
l9pic: '',
l8name: '장비',
l8pic: '',
l7name: '법정',
l7pic: '',
l6name: '조운',
l6pic: '',
l5name: '마량',
l5pic: '',
tiger: '관우【10】',
eagle: '방통【7】',
gen: '유비, 제갈량',
history: ['<C>●</>촉이 천하를 통일'],
aux: { winnerNationId: 1, privateNote: 'not-returned' },
};
const oldNation = {
id: 3,
serverId: emperor.serverId,
nation: 1,
data: {
nation: 1,
name: '촉',
color: '#FF0000',
type: 'che_병가',
level: 7,
tech: 4000,
power: 34434,
maxCrew: 120000,
maxCities: ['성도', '한중'],
generals: [11, 12],
history: ['<Y>유비</>가 황제로 즉위'],
owner: 'not-returned',
},
date: new Date('2026-07-25T12:00:00.000Z'),
};
const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload => ({
version: 1,
profile: profile.name,
issuedAt: '2026-07-25T00:00:00.000Z',
expiresAt: '2026-07-26T00:00:00.000Z',
sessionId: `session-${userId}`,
user: {
id: userId,
username: userId,
displayName: userId,
roles,
},
sanctions: {},
});
const buildContext = (auth: GameSessionTokenPayload | null): GameApiContext => {
const db = {
worldState: {
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
},
emperor: {
findMany: async () => [emperor],
findUnique: async ({ where }: { where: { id: number } }) => (where.id === emperor.id ? emperor : null),
},
oldNation: {
findMany: async ({ where }: { where: { serverId: string } }) =>
where.serverId === emperor.serverId ? [oldNation] : [],
},
oldGeneral: {
findMany: async () => [
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
],
},
};
const redis = {
get: async () => null,
set: async () => null,
} as unknown as RedisConnector['client'];
return {
db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile,
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
redis,
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
};
describe('dynasty public read model', () => {
it('returns the current row and the complete legacy officer summary', async () => {
const result = await appRouter.createCaller(buildContext(null)).dynasty.getList();
expect(result.current).toEqual({ year: 220, month: 1 });
expect(result.entries).toEqual([
expect.objectContaining({
id: 7,
serverId: 'hwe_260725_fixture',
phase: '훼2기',
name: '촉',
l12name: '유비',
l11name: '제갈량',
l10name: '관우',
l9name: '방통',
l8name: '장비',
l7name: '법정',
l6name: '조운',
l5name: '마량',
}),
]);
});
it('exposes the same public DTO to anonymous, general owners and admins', async () => {
const anonymous = appRouter.createCaller(buildContext(null));
const owner = appRouter.createCaller(buildContext(authFor('owner-a')));
const otherOwner = appRouter.createCaller(buildContext(authFor('owner-b')));
const admin = appRouter.createCaller(buildContext(authFor('admin', ['admin'])));
const [anonymousList, ownerList, otherOwnerList, adminList] = await Promise.all([
anonymous.dynasty.getList(),
owner.dynasty.getList(),
otherOwner.dynasty.getList(),
admin.dynasty.getList(),
]);
expect(ownerList).toEqual(anonymousList);
expect(otherOwnerList).toEqual(anonymousList);
expect(adminList).toEqual(anonymousList);
const [anonymousDetail, ownerDetail, otherOwnerDetail, adminDetail] = await Promise.all([
anonymous.dynasty.getDetail({ emperorId: emperor.id }),
owner.dynasty.getDetail({ emperorId: emperor.id }),
otherOwner.dynasty.getDetail({ emperorId: emperor.id }),
admin.dynasty.getDetail({ emperorId: emperor.id }),
]);
expect(ownerDetail).toEqual(anonymousDetail);
expect(otherOwnerDetail).toEqual(anonymousDetail);
expect(adminDetail).toEqual(anonymousDetail);
});
it('returns only the legacy public archive fields and resolves general names', async () => {
const result = await appRouter.createCaller(buildContext(authFor('owner-a'))).dynasty.getDetail({
emperorId: emperor.id,
});
expect(result.emperor).toEqual(
expect.objectContaining({
personalHist: '의리(4)',
specialHist: '상재(2)',
tiger: '관우【10】',
eagle: '방통【7】',
history: ['<C>●</>촉이 천하를 통일'],
})
);
expect(result.nations).toEqual([
expect.objectContaining({
name: '촉',
type: 'che_병가',
typeName: '병가',
levelName: '황제',
maxPower: 34434,
generalsFull: [
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
],
}),
]);
expect(JSON.stringify(result)).not.toContain('privateNote');
expect(JSON.stringify(result)).not.toContain('not-returned');
expect(JSON.stringify(result)).not.toContain('"owner"');
expect(JSON.stringify(result)).not.toContain('"data"');
});
it('rejects invalid and missing record identifiers without querying another scope', async () => {
const caller = appRouter.createCaller(buildContext(null));
await expect(caller.dynasty.getDetail({ emperorId: 0 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await expect(caller.dynasty.getDetail({ emperorId: 999 })).rejects.toMatchObject({
code: 'NOT_FOUND',
});
});
});
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js';
const profile: GameProfile = {
id: 'che',
scenario: 'default',
name: 'che:default',
};
const archiveServerId = 'hwe_260725_archive';
const archiveRows = [
{
id: 1,
profileName: archiveServerId,
year: 200,
month: 1,
map: { year: 200, month: 1, startYear: 190, cityList: [], nationList: [] },
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1000, cities: ['성도'] }],
hash: 'archive-1',
createdAt: new Date('2026-07-25T00:00:00.000Z'),
},
{
id: 2,
profileName: archiveServerId,
year: 200,
month: 2,
map: { year: 200, month: 2, startYear: 190, cityList: [], nationList: [] },
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1200, cities: ['성도'] }],
hash: 'archive-2',
createdAt: new Date('2026-07-25T01:00:00.000Z'),
},
];
const authFor = (userId: string): GameSessionTokenPayload => ({
version: 1,
profile: profile.name,
issuedAt: '2026-07-25T00:00:00.000Z',
expiresAt: '2026-07-26T00:00:00.000Z',
sessionId: `session-${userId}`,
user: {
id: userId,
username: userId,
displayName: userId,
roles: [],
},
sanctions: {},
});
const buildContext = (
auth: GameSessionTokenPayload | null,
options: { hasGeneral?: boolean } = {}
): GameApiContext => {
const db = {
general: {
findFirst: async ({ where }: { where: { userId: string } }) =>
options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId },
},
worldState: {
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
},
yearbookHistory: {
findFirst: async (args: {
where: { profileName: string; year?: number; month?: number };
orderBy?: Array<{ year?: 'asc' | 'desc'; month?: 'asc' | 'desc' }>;
}) => {
const candidates = archiveRows.filter(
(row) =>
row.profileName === args.where.profileName &&
(args.where.year === undefined || row.year === args.where.year) &&
(args.where.month === undefined || row.month === args.where.month)
);
if (!args.orderBy) {
return candidates[0] ?? null;
}
const descending = args.orderBy[0]?.year === 'desc';
return (descending ? candidates.at(-1) : candidates[0]) ?? null;
},
},
};
const redis = {
get: async () => null,
set: async () => null,
} as unknown as RedisConnector['client'];
return {
db: db as unknown as DatabaseClient,
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile,
auth,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
redis,
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
};
describe('historical yearbook access from dynasty', () => {
it('selects the archived server range without treating it as the live month', async () => {
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
const result = await caller.yearbook.getRange({ serverID: archiveServerId });
expect(result).toEqual({
firstYearMonth: 200 * 12,
lastYearMonth: 200 * 12 + 1,
currentYearMonth: 200 * 12 + 1,
});
});
it('returns the same archived public history for distinct general owners', async () => {
const ownerA = appRouter.createCaller(buildContext(authFor('owner-a')));
const ownerB = appRouter.createCaller(buildContext(authFor('owner-b')));
const [resultA, resultB] = await Promise.all([
ownerA.yearbook.getHistory({ serverID: archiveServerId, year: 200, month: 2 }),
ownerB.yearbook.getHistory({ serverID: archiveServerId, year: 200, month: 2 }),
]);
expect(resultB).toEqual(resultA);
expect(resultA).toMatchObject({
notModified: false,
data: {
year: 200,
month: 2,
nations: [{ id: 1, name: '촉', cities: ['성도'] }],
globalHistory: ['<C>●</>2월: 기록 없음'],
globalAction: ['<C>●</>2월: 기록 없음'],
},
});
});
it('requires both an authenticated user and a user-owned general like legacy v_history.php', async () => {
const anonymous = appRouter.createCaller(buildContext(null));
const noGeneral = appRouter.createCaller(buildContext(authFor('owner-a'), { hasGeneral: false }));
await expect(anonymous.yearbook.getRange({ serverID: archiveServerId })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
await expect(noGeneral.yearbook.getRange({ serverID: archiveServerId })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: 'General not found',
});
});
it('rejects unknown archived server IDs instead of falling back to the live profile', async () => {
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
await expect(caller.yearbook.getRange({ serverID: 'missing-server' })).rejects.toMatchObject({
code: 'NOT_FOUND',
message: '연감 범위를 찾을 수 없습니다.',
});
});
});
+1
View File
@@ -240,6 +240,7 @@ const routes = [
component: YearbookView,
meta: {
requiresAuth: true,
requiresGeneral: true,
},
},
{
@@ -0,0 +1,23 @@
const lightTextBackgrounds = new Set([
'',
'#330000',
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#6495ED',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#800080',
'#A9A9A9',
'#000000',
]);
export const legacyNationTextColor = (backgroundColor: string): '#FFFFFF' | '#000000' =>
lightTextBackgrounds.has(backgroundColor.toUpperCase()) ? '#FFFFFF' : '#000000';
+371 -218
View File
@@ -1,66 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { formatLog } from '../utils/formatLog';
import { legacyNationTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc';
type EmperorDetail = {
id: number;
serverId: string;
winnerNationId: number | null;
phase: string;
nationCount: string;
nationName: string;
nationHist: string;
genCount: string;
personalHist: string;
specialHist: string;
name: string;
type: string;
color: string;
year: number;
month: number;
power: number;
gennum: number;
citynum: number;
pop: string;
poprate: string;
gold: number;
rice: number;
l12name: string;
l11name: string;
l10name: string;
l9name: string;
l8name: string;
l7name: string;
l6name: string;
l5name: string;
tiger: string;
eagle: string;
gen: string;
history: string[];
};
type NationEntry = {
nation: number;
isWinner: boolean;
name: string;
color: string;
type: string;
level: number | null;
tech: number | null;
maxPower: number | null;
maxCrew: number | null;
maxCities: number[];
generals: number[];
history: string[];
date: string;
generalsFull: Array<{ generalNo: number; name: string; lastYearMonth: number | null }>;
};
type DynastyDetailPayload = {
emperor: EmperorDetail;
nations: NationEntry[];
};
type DynastyDetailPayload = Awaited<ReturnType<typeof trpc.dynasty.getDetail.query>>;
const route = useRoute();
const router = useRouter();
@@ -72,191 +18,398 @@ const emperorId = computed(() => {
const idParam = route.params.id;
const raw = Array.isArray(idParam) ? idParam[0] : idParam;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
});
const dynastyApi = trpc as unknown as {
dynasty: {
getDetail: {
query: (input: { emperorId: number }) => Promise<DynastyDetailPayload>;
};
};
const closePage = async (): Promise<void> => {
if (window.opener) {
window.close();
return;
}
await router.push('/');
};
const loadDetail = async () => {
const loadDetail = async (): Promise<void> => {
if (emperorId.value === null) {
data.value = null;
errorMessage.value = '잘못된 왕조 번호입니다.';
return;
}
loading.value = true;
errorMessage.value = '';
try {
data.value = await dynastyApi.dynasty.getDetail.query({
emperorId: emperorId.value,
});
data.value = await trpc.dynasty.getDetail.query({ emperorId: emperorId.value });
} catch (error) {
data.value = null;
errorMessage.value = error instanceof Error ? error.message : '왕조 정보를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
watch(emperorId, () => {
void loadDetail();
});
const formatArchiveDate = (value: string): string =>
new Intl.DateTimeFormat('sv-SE', {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: 'UTC',
}).format(new Date(value));
onMounted(async () => {
await loadDetail();
});
watch(emperorId, loadDetail);
onMounted(loadDetail);
</script>
<template>
<main class="main-page">
<header class="page-header">
<div>
<h1 class="page-title">왕조 상세</h1>
<p class="page-subtitle">통일 세력과 멸망한 국가의 기록입니다.</p>
</div>
<div class="header-actions">
<button class="ghost" @click="router.push('/dynasty')">목록으로</button>
<button class="ghost" @click="loadDetail">데이터 새로고침</button>
</div>
</header>
<main id="dynasty-detail-container" class="dynasty-page">
<table class="legacy-table legacy-bg0 title-table">
<tbody>
<tr>
<td>
<br />
<button class="native-button" type="button" @click="closePage"> 닫기</button>
<span class="all-link">
<RouterLink to="/dynasty"><button class="native-button" type="button">전체보기</button></RouterLink>
</span>
</td>
</tr>
</tbody>
</table>
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
<div v-else-if="loading" class="placeholder">불러오는 중...</div>
<div v-else-if="!data" class="placeholder">표시할 데이터가 없습니다.</div>
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading && !data" class="legacy-message" role="status">불러오는 중...</div>
<section v-if="data" class="grid gap-4">
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<div class="flex items-center gap-3 mb-3">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: data.emperor.color }" />
<div>
<h2 class="text-lg font-semibold">{{ data.emperor.name }}</h2>
<p class="text-xs text-zinc-400">{{ data.emperor.phase }} · {{ data.emperor.year }} {{ data.emperor.month }}</p>
</div>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-2 text-sm text-zinc-300">
<div>국력: {{ data.emperor.power }}</div>
<div>도시: {{ data.emperor.citynum }}</div>
<div>장수: {{ data.emperor.gennum }}</div>
<div>인구: {{ data.emperor.pop }}</div>
<div>인구율: {{ data.emperor.poprate }}</div>
<div>/: {{ data.emperor.gold }} / {{ data.emperor.rice }}</div>
</div>
</article>
<template v-if="data">
<table class="legacy-table legacy-bg0 emperor-table">
<colgroup>
<col class="short-label" />
<col class="picture-column" />
<col class="long-value" />
<col class="short-label" />
<col class="picture-column" />
<col class="long-value" />
</colgroup>
<tbody>
<tr>
<td class="phase-heading centered" colspan="6">
<span class="large-text">{{ data.emperor.phase }}</span>
</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> <br />(최종 / 최대)</td>
<td class="centered" colspan="2">{{ data.emperor.nationCount }}</td>
<td class="legacy-bg1 centered"> <br />(최종 / 최대)</td>
<td class="centered" colspan="2">{{ data.emperor.genCount }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td colspan="5">{{ data.emperor.nationName }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered">국가별 성향</td>
<td colspan="5">{{ data.emperor.nationHist }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td colspan="5">{{ data.emperor.personalHist }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td colspan="5">{{ data.emperor.specialHist }}</td>
</tr>
<tr>
<td
class="nation-heading centered"
colspan="6"
:style="{
backgroundColor: data.emperor.color,
color: legacyNationTextColor(data.emperor.color),
}"
>
<span class="large-text">
{{ data.emperor.name }} ({{ data.emperor.year }} {{ data.emperor.month }})
</span>
</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.power }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.type }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.gennum }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.citynum }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.pop }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.poprate }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.gold }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered" colspan="2">{{ data.emperor.rice }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l12name }}</td>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l11name }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l10name }}</td>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l9name }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l8name }}</td>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l7name }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l6name }}</td>
<td class="legacy-bg1 centered"> </td>
<td>&nbsp;</td>
<td>{{ data.emperor.l5name }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td colspan="5">{{ data.emperor.tiger }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td colspan="5">{{ data.emperor.eagle }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> <br />(공헌도 순서)</td>
<td colspan="5">{{ data.emperor.gen }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td colspan="5">
<!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-for="(entry, index) in data.emperor.history" :key="index" v-html="formatLog(entry)" />
</td>
</tr>
</tbody>
</table>
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h3 class="text-base font-semibold mb-2">왕조 구성</h3>
<div class="grid sm:grid-cols-2 gap-2 text-sm text-zinc-300">
<div>세력 : {{ data.emperor.nationCount }}</div>
<div>장수 : {{ data.emperor.genCount }}</div>
<div>세력 목록: {{ data.emperor.nationName }}</div>
<div>세력 유형: {{ data.emperor.nationHist }}</div>
</div>
</article>
<table
v-for="nation in data.nations"
:key="nation.nation"
class="legacy-table legacy-bg2 old-nation-table"
>
<colgroup>
<col class="old-label" />
<col class="old-value" />
<col class="old-label" />
<col class="old-value" />
<col class="old-label" />
<col class="old-value" />
</colgroup>
<thead>
<tr>
<td
class="centered"
colspan="6"
:style="{ backgroundColor: nation.color, color: legacyNationTextColor(nation.color) }"
>
{{ nation.name }}
</td>
</tr>
</thead>
<tbody class="centered">
<tr>
<td class="legacy-bg1">성향</td>
<td>{{ nation.typeName }}</td>
<td class="legacy-bg1">-</td>
<td>-</td>
<td class="legacy-bg1">일자</td>
<td>{{ formatArchiveDate(nation.date) }}</td>
</tr>
<tr>
<td class="legacy-bg1">최종 작위</td>
<td>{{ nation.levelName }}</td>
<td class="legacy-bg1">최종 장수 </td>
<td>{{ nation.generals.length }}</td>
<td class="legacy-bg1">기술력</td>
<td>{{ nation.tech ?? '' }}</td>
</tr>
<tr>
<td class="legacy-bg1">최대 영토 </td>
<td>{{ nation.maxCities.length }}</td>
<td class="legacy-bg1">최대 병력 </td>
<td>{{ nation.maxCrew ?? 0 }}</td>
<td class="legacy-bg1">최대 국력</td>
<td>{{ nation.maxPower ?? 0 }}</td>
</tr>
<tr>
<td class="legacy-bg1 top-cell">최대영토</td>
<td colspan="5">{{ nation.maxCities.join(', ') }}</td>
</tr>
<tr>
<td class="legacy-bg1 top-cell">장수명단</td>
<td colspan="5">
<template v-for="general in nation.generalsFull" :key="general.generalNo">
{{ general.name }},
</template>
</td>
</tr>
<tr>
<td class="legacy-bg1 top-cell">국가열전</td>
<td class="legacy-bg0 nation-history" colspan="5">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-for="(entry, index) in nation.history" :key="index" v-html="formatLog(entry)" />
</td>
</tr>
</tbody>
</table>
</template>
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h3 class="text-base font-semibold mb-2">관직</h3>
<ul class="grid sm:grid-cols-2 gap-2 text-sm text-zinc-300">
<li v-if="data.emperor.l12name">승상: {{ data.emperor.l12name }}</li>
<li v-if="data.emperor.l11name">태사: {{ data.emperor.l11name }}</li>
<li v-if="data.emperor.l10name">태부: {{ data.emperor.l10name }}</li>
<li v-if="data.emperor.l9name">태위: {{ data.emperor.l9name }}</li>
<li v-if="data.emperor.l8name">사도: {{ data.emperor.l8name }}</li>
<li v-if="data.emperor.l7name">사공: {{ data.emperor.l7name }}</li>
<li v-if="data.emperor.l6name">상서령: {{ data.emperor.l6name }}</li>
<li v-if="data.emperor.l5name">상서: {{ data.emperor.l5name }}</li>
</ul>
<div v-if="!data.emperor.l12name && !data.emperor.l11name" class="text-xs text-zinc-500">등록된 관직 정보가 없습니다.</div>
</article>
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h3 class="text-base font-semibold mb-2">장수 기록</h3>
<div class="text-sm text-zinc-300 space-y-2">
<div>
<span class="text-xs text-zinc-400">호랑이(전공):</span>
<div>{{ data.emperor.tiger || '기록 없음' }}</div>
</div>
<div>
<span class="text-xs text-zinc-400">독수리(방화):</span>
<div>{{ data.emperor.eagle || '기록 없음' }}</div>
</div>
<div>
<span class="text-xs text-zinc-400">헌납 순위:</span>
<div>{{ data.emperor.gen || '기록 없음' }}</div>
</div>
</div>
</article>
<article class="bg-zinc-900 border border-zinc-800 rounded p-4">
<h3 class="text-base font-semibold mb-2">통일 세력 연표</h3>
<div v-if="data.emperor.history.length === 0" class="text-xs text-zinc-500">연표가 없습니다.</div>
<ul v-else class="space-y-2 text-sm text-zinc-300">
<li v-for="(entry, index) in data.emperor.history" :key="`${data.emperor.id}-hist-${index}`">
{{ entry }}
</li>
</ul>
</article>
<section class="grid gap-4">
<header class="flex items-center justify-between">
<h3 class="text-base font-semibold">국가 기록</h3>
<span class="text-xs text-zinc-400"> {{ data.nations.length }}</span>
</header>
<article
v-for="nation in data.nations"
:key="nation.nation"
class="bg-zinc-900 border border-zinc-800 rounded p-4"
>
<div class="flex items-center gap-3 mb-2">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: nation.color }" />
<div>
<h4 class="text-base font-semibold">
{{ nation.name }}
<span
v-if="nation.isWinner"
class="ml-2 inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-200"
>
통일 세력
</span>
</h4>
<p class="text-xs text-zinc-400">{{ nation.type || '미상' }} · {{ new Date(nation.date).toLocaleString() }}</p>
</div>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-2 text-sm text-zinc-300 mb-3">
<div>레벨: {{ nation.level ?? '-' }}</div>
<div>기술: {{ nation.tech ?? '-' }}</div>
<div>최대 국력: {{ nation.maxPower ?? '-' }}</div>
<div>최대 병력: {{ nation.maxCrew ?? '-' }}</div>
<div>최대 도시: {{ nation.maxCities.join(', ') || '-' }}</div>
<div>장수 : {{ nation.generals.length }}</div>
</div>
<div class="text-sm text-zinc-300">
<h5 class="text-xs text-zinc-400 mb-1">장수 목록</h5>
<div v-if="nation.generalsFull.length === 0" class="text-xs text-zinc-500">기록된 장수가 없습니다.</div>
<ul v-else class="flex flex-wrap gap-2">
<li
v-for="general in nation.generalsFull"
:key="general.generalNo"
class="bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-xs"
>
{{ general.name }}<span v-if="general.lastYearMonth"> ({{ general.lastYearMonth }})</span>
</li>
</ul>
</div>
<div class="text-sm text-zinc-300 mt-3">
<h5 class="text-xs text-zinc-400 mb-1">국가 연표</h5>
<div v-if="nation.history.length === 0" class="text-xs text-zinc-500">연표가 없습니다.</div>
<ul v-else class="space-y-1">
<li v-for="(entry, index) in nation.history" :key="`${nation.nation}-hist-${index}`">
{{ entry }}
</li>
</ul>
</div>
</article>
</section>
</section>
<table class="legacy-table legacy-bg0 footer-table">
<tbody>
<tr>
<td>
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
</td>
</tr>
<tr>
<td class="banner">
삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
:global(body) {
min-width: 1016px;
}
.dynasty-page {
width: 1000px;
margin: 8px auto 0;
color: #fff;
font-family: 'Times New Roman', serif;
font-size: 16px;
line-height: normal;
}
.legacy-table {
width: 1000px;
border-spacing: 2px;
}
.legacy-table td {
padding: 1px;
}
.title-table {
height: 47px;
}
.all-link {
float: right;
}
.emperor-table {
margin-top: 0;
}
.short-label {
width: 98px;
}
.picture-column {
width: 64px;
}
.long-value {
width: 332px;
}
.phase-heading {
background: skyblue;
color: #000;
}
.large-text {
font-size: x-large;
}
.centered {
text-align: center;
}
.old-nation-table {
margin: 20px auto 0;
}
.old-label {
width: 98px;
}
.old-value {
width: 238px;
}
.top-cell {
vertical-align: top;
}
.nation-history {
text-align: left;
}
.native-button {
appearance: auto;
box-sizing: border-box;
border: 2px outset buttonborder;
border-radius: 0;
padding: 1px 6px;
background: buttonface;
color: buttontext;
font-family: Arial;
font-size: 13.3333px;
font-weight: 400;
line-height: normal;
cursor: default;
}
.legacy-message {
margin-top: 10px;
border: 1px solid gray;
padding: 12px;
background: #302016;
text-align: center;
}
.legacy-message.error {
color: #ff6b6b;
}
.footer-table {
min-height: 50px;
margin-top: 20px;
}
.banner {
font-size: 13px;
}
</style>
+229 -59
View File
@@ -1,35 +1,30 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { legacyNationTextColor } from '../utils/legacyNationColor';
import { trpc } from '../utils/trpc';
type DynastyEntry = {
id: number;
serverId: string;
phase: string;
name: string;
year: number;
month: number;
color: string;
type: string;
power: number;
gennum: number;
citynum: number;
};
type DynastyListPayload = {
entries: DynastyEntry[];
};
type DynastyListPayload = Awaited<ReturnType<typeof trpc.dynasty.getList.query>>;
const router = useRouter();
const loading = ref(false);
const errorMessage = ref('');
const entries = ref<DynastyEntry[]>([]);
const data = ref<DynastyListPayload | null>(null);
const loadDynasty = async () => {
const closePage = async (): Promise<void> => {
if (window.opener) {
window.close();
return;
}
await router.push('/');
};
const loadDynasty = async (): Promise<void> => {
loading.value = true;
errorMessage.value = '';
try {
const result = (await trpc.dynasty.getList.query()) as DynastyListPayload;
entries.value = result.entries;
data.value = await trpc.dynasty.getList.query();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '왕조일람을 불러오지 못했습니다.';
} finally {
@@ -37,50 +32,225 @@ const loadDynasty = async () => {
}
};
onMounted(async () => {
await loadDynasty();
});
onMounted(loadDynasty);
</script>
<template>
<main class="main-page">
<header class="page-header">
<div>
<h1 class="page-title">왕조일람</h1>
<p class="page-subtitle">천하통일로 정리된 왕조 기록을 확인합니다.</p>
</div>
<div class="header-actions">
<button class="ghost" @click="loadDynasty">목록 새로고침</button>
</div>
</header>
<main id="dynasty-list-container" class="dynasty-page">
<table class="legacy-table legacy-bg0 title-table">
<tbody>
<tr>
<td>
<br />
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
</td>
</tr>
</tbody>
</table>
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
<div v-else-if="loading" class="placeholder">불러오는 중...</div>
<div v-else-if="entries.length === 0" class="placeholder">표시할 왕조 기록이 없습니다.</div>
<div v-if="errorMessage" class="legacy-message error" role="alert">{{ errorMessage }}</div>
<div v-else-if="loading && !data" class="legacy-message" role="status">불러오는 중...</div>
<section v-else class="grid gap-4">
<RouterLink
v-for="entry in entries"
<template v-if="data">
<table v-if="data.current" class="legacy-table legacy-bg0 current-table spaced-table">
<tbody>
<tr>
<td class="current-heading" colspan="8">
<span class="large-text">현재 ({{ data.current.year }} {{ data.current.month }})</span>
<RouterLink to="/yearbook"><button class="native-button" type="button">역사 보기</button></RouterLink>
</td>
</tr>
</tbody>
</table>
<div v-if="data.entries.length === 0" class="legacy-message">표시할 왕조 기록이 없습니다.</div>
<table
v-for="entry in data.entries"
:key="entry.id"
class="block bg-zinc-900 border border-zinc-800 rounded p-4 hover:border-zinc-600 transition"
:to="`/dynasty/${entry.id}`"
class="legacy-table legacy-bg0 dynasty-table spaced-table"
>
<div class="flex items-center gap-3 mb-2">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: entry.color }" />
<div>
<h2 class="text-lg font-semibold">{{ entry.name }}</h2>
<p class="text-xs text-zinc-400">
{{ entry.phase }} · {{ entry.year }} {{ entry.month }}
</p>
</div>
</div>
<div class="grid sm:grid-cols-3 gap-2 text-sm text-zinc-300">
<div>세력 유형: {{ entry.type || '미상' }}</div>
<div>도시: {{ entry.citynum }}</div>
<div>장수: {{ entry.gennum }}</div>
<div class="sm:col-span-3">국력: {{ entry.power }}</div>
</div>
</RouterLink>
</section>
<colgroup>
<col class="label-column" />
<col class="value-column" />
<col class="label-column" />
<col class="value-column" />
<col class="label-column" />
<col class="value-column" />
<col class="label-column" />
<col class="value-column" />
</colgroup>
<tbody>
<tr>
<td class="phase-heading" colspan="8">
<span class="large-text">{{ entry.phase }}</span>
<RouterLink :to="`/dynasty/${entry.id}`">
<button class="native-button" type="button">자세히</button>
</RouterLink>
<RouterLink v-if="entry.serverId" :to="{ path: '/yearbook', query: { serverID: entry.serverId } }">
<button class="native-button" type="button">역사 보기</button>
</RouterLink>
</td>
</tr>
<tr>
<td
class="nation-heading"
colspan="8"
:style="{ backgroundColor: entry.color, color: legacyNationTextColor(entry.color) }"
>
<span class="large-text">{{ entry.name }} ({{ entry.year }} {{ entry.month }})</span>
</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.power }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.gennum }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.citynum }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.type }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l12name }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l11name }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l10name }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l9name }}</td>
</tr>
<tr>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l8name }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l7name }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l6name }}</td>
<td class="legacy-bg1 centered"> </td>
<td class="centered">{{ entry.l5name }}</td>
</tr>
</tbody>
</table>
</template>
<table class="legacy-table legacy-bg0 footer-table spaced-table">
<tbody>
<tr>
<td>
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
</td>
</tr>
<tr>
<td class="banner">
삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td>
</tr>
</tbody>
</table>
</main>
</template>
<style scoped>
:global(body) {
min-width: 1016px;
}
.dynasty-page {
width: 1000px;
margin: 8px auto 0;
color: #fff;
font-family: 'Times New Roman', serif;
font-size: 16px;
line-height: normal;
}
.legacy-table {
width: 1000px;
border-spacing: 2px;
}
.legacy-table td {
padding: 1px;
}
.spaced-table {
margin-top: 10px;
}
.title-table {
height: 47px;
}
.current-table {
height: 37px;
}
.dynasty-table {
height: 139px;
}
.current-heading,
.phase-heading,
.nation-heading,
.centered {
text-align: center;
}
.current-heading {
background: #333;
}
.phase-heading {
background: skyblue;
color: #000;
}
.large-text {
font-size: x-large;
}
.label-column {
width: 80px;
}
.value-column {
width: 170px;
}
.native-button {
appearance: auto;
box-sizing: border-box;
border: 2px outset buttonborder;
border-radius: 0;
padding: 1px 6px;
background: buttonface;
color: buttontext;
font-family: Arial;
font-size: 13.3333px;
font-weight: 400;
line-height: normal;
cursor: default;
}
.legacy-message {
margin-top: 10px;
border: 1px solid gray;
padding: 12px;
background: #302016;
text-align: center;
}
.legacy-message.error {
color: #ff6b6b;
}
.footer-table {
min-height: 50px;
}
.banner {
font-size: 13px;
}
</style>
+9 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import MapViewer from '../components/main/MapViewer.vue';
import { formatLog } from '../utils/formatLog';
@@ -25,12 +25,18 @@ type HistoryData = {
};
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const errorMessage = ref('');
const range = ref<YearbookRange | null>(null);
const mapLayout = ref<MapLayout | null>(null);
const history = ref<HistoryData | null>(null);
const selectedYearMonth = ref<number | null>(null);
const serverID = computed(() => {
const value = route.query.serverID;
const raw = Array.isArray(value) ? value[0] : value;
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
});
const parseYearMonth = (value: number): { year: number; month: number } => ({
year: Math.floor(value / 12),
@@ -66,7 +72,7 @@ const loadHistory = async (): Promise<void> => {
errorMessage.value = '';
try {
const { year, month } = parseYearMonth(selectedYearMonth.value);
const result = await trpc.yearbook.getHistory.query({ year, month });
const result = await trpc.yearbook.getHistory.query({ year, month, serverID: serverID.value });
if ('data' in result) {
history.value = result.data;
}
@@ -96,7 +102,7 @@ onMounted(async () => {
loading.value = true;
try {
const [loadedRange, loadedLayout] = await Promise.all([
trpc.yearbook.getRange.query(),
trpc.yearbook.getRange.query(serverID.value ? { serverID: serverID.value } : undefined),
trpc.public.getMapLayout.query(),
]);
range.value = loadedRange;
@@ -0,0 +1,293 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { resolve } from 'node:path';
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
const gameOrigin = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}`;
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, code: 'BAD_REQUEST' | 'NOT_FOUND', message: string) => ({
error: {
message,
code: -32000,
data: { code, httpStatus: code === 'NOT_FOUND' ? 404 : 400, path },
},
});
const operationNames = (route: Route): string[] => {
const pathname = new URL(route.request().url()).pathname;
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const listPayload = {
current: { year: 200, month: 1 },
entries: [
{
id: 1,
serverId: 'hwe_260725_u3uE',
phase: '훼2기',
name: '백년01',
year: 215,
month: 4,
color: '#FF0000',
type: 'che_병가',
power: 34434,
gennum: 71,
citynum: 78,
l12name: 'ⓜ⑮오리온자리',
l11name: 'ⓜ②물조명성너프필수',
l10name: 'ⓜ㉗좌우쌍욍검유비',
l9name: 'ⓜ②다니엘레메로시',
l8name: 'ⓜ⑤쿠로',
l7name: 'ⓜ⑮료우기시키',
l6name: 'ⓜ㉓랜덤박스',
l5name: 'ⓜ㉒임사영',
},
],
};
const detailPayload = {
emperor: {
id: 1,
serverId: 'hwe_260725_u3uE',
winnerNationId: 1,
phase: '훼2기',
nationCount: '8 / 17',
nationName: '백년01, 청해, 백제',
nationHist: '병가(3), 유가(2)',
genCount: '71 / 92',
personalHist: '의리(14), 대담(11)',
specialHist: '상재(8), 견고(7)',
name: '백년01',
type: 'che_병가',
color: '#FF0000',
year: 215,
month: 4,
power: 34434,
gennum: 71,
citynum: 78,
pop: '2,345,000 / 2,500,000',
poprate: '93.8 %',
gold: 120000,
rice: 150000,
l12name: 'ⓜ⑮오리온자리',
l11name: 'ⓜ②물조명성너프필수',
l10name: 'ⓜ㉗좌우쌍욍검유비',
l9name: 'ⓜ②다니엘레메로시',
l8name: 'ⓜ⑤쿠로',
l7name: 'ⓜ⑮료우기시키',
l6name: 'ⓜ㉓랜덤박스',
l5name: 'ⓜ㉒임사영',
tiger: '관우【10】, 조운【9】',
eagle: '주유【7】, 육손【6】',
gen: '오리온자리, 물조명성너프필수, 좌우쌍욍검유비',
history: ['<C>●</><Y><b>【통일】</b></><D><b>백년01</b></>이 전토를 통일하였습니다.'],
},
nations: [
{
nation: 1,
isWinner: true,
name: '백년01',
color: '#FF0000',
type: 'che_병가',
typeName: '병가',
level: 7,
levelName: '황제',
tech: 4000,
maxPower: 34434,
maxCrew: 120000,
maxCities: ['낙양', '장안', '성도'],
generals: [11, 12],
history: ['<Y>오리온자리</>가 황제로 즉위'],
date: '2026-07-25T12:00:00.000Z',
generalsFull: [
{ generalNo: 11, name: '오리온자리', lastYearMonth: 21504 },
{ generalNo: 12, name: '료우기시키', lastYearMonth: 21504 },
],
},
],
};
const installFixture = async (page: Page): Promise<void> => {
await page.route('**/image/game/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/png',
body: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64'
),
});
});
await page.route('**/che/api/trpc/**', async (route) => {
const results = operationNames(route).map((operation) => {
if (operation === 'lobby.info') {
return response({ myGeneral: null });
}
if (operation === 'dynasty.getList') {
return response(listPayload);
}
if (operation === 'dynasty.getDetail') {
const input = new URL(route.request().url()).searchParams.get('input') ?? '';
return input.includes('999')
? errorResponse(operation, 'NOT_FOUND', '왕조 정보를 찾을 수 없습니다.')
: response(detailPayload);
}
return errorResponse(operation, 'BAD_REQUEST', `Unhandled fixture operation: ${operation}`);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
};
test.beforeEach(async ({ page }) => {
await installFixture(page);
await page.setViewportSize({ width: 1280, height: 900 });
});
test('dynasty list matches the ref Chromium table geometry and interactions', async ({ page }) => {
await page.goto(`${gameOrigin}/che/dynasty`);
await expect(page.getByText('백년01 (215年 4月)')).toBeVisible();
const geometry = await page.locator('#dynasty-list-container').evaluate((container) => {
const rect = container.getBoundingClientRect();
const tables = Array.from(container.querySelectorAll<HTMLTableElement>('table')).map((table) => {
const tableRect = table.getBoundingClientRect();
const style = getComputedStyle(table);
return {
x: tableRect.x,
y: tableRect.y,
width: tableRect.width,
height: tableRect.height,
borderSpacing: style.borderSpacing,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
};
});
const button = container.querySelector<HTMLButtonElement>('button')!;
const buttonStyle = getComputedStyle(button);
const cellStyle = getComputedStyle(container.querySelector<HTMLTableCellElement>('td')!);
return {
container: { x: rect.x, y: rect.y, width: rect.width },
tables,
button: {
height: button.getBoundingClientRect().height,
borderWidth: buttonStyle.borderWidth,
borderRadius: buttonStyle.borderRadius,
padding: buttonStyle.padding,
fontFamily: buttonStyle.fontFamily,
fontSize: buttonStyle.fontSize,
cursor: buttonStyle.cursor,
},
firstCell: {
padding: cellStyle.padding,
borderWidth: cellStyle.borderWidth,
textAlign: cellStyle.textAlign,
},
};
});
expect(geometry.container).toEqual({ x: 140, y: 8, width: 1000 });
expect(geometry.tables.slice(0, 3).map(({ x, width, borderSpacing }) => ({ x, width, borderSpacing }))).toEqual([
{ x: 140, width: 1000, borderSpacing: '2px' },
{ x: 140, width: 1000, borderSpacing: '2px' },
{ x: 140, width: 1000, borderSpacing: '2px' },
]);
expect(geometry.tables.slice(0, 3).map(({ y, height }) => ({ y, height }))).toEqual([
{ y: 8, height: 47 },
{ y: 65, height: 37 },
{ y: 112, height: 139 },
]);
expect(geometry.tables[0]!.fontFamily).toContain('Times New Roman');
expect(geometry.tables[0]!.fontSize).toBe('16px');
expect(geometry.tables[0]!.lineHeight).toBe('normal');
expect(geometry.button).toEqual({
height: 22,
borderWidth: '2px',
borderRadius: '0px',
padding: '1px 6px',
fontFamily: 'Arial',
fontSize: '13.3333px',
cursor: 'default',
});
expect(geometry.firstCell).toEqual({ padding: '1px', borderWidth: '0px', textAlign: 'start' });
const historyLink = page.getByRole('link', { name: '역사 보기' }).last();
await expect(historyLink).toHaveAttribute('href', '/che/yearbook?serverID=hwe_260725_u3uE');
if (artifactRoot) {
await page.screenshot({ path: resolve(artifactRoot, 'dynasty-core-list.png'), fullPage: true });
}
const detailLink = page.getByRole('link', { name: '자세히' });
await detailLink.focus();
await expect(detailLink).toBeFocused();
await detailLink.click();
await expect(page).toHaveURL(/\/che\/dynasty\/1$/);
await expect(page.getByText('국 가 수')).toBeVisible();
if (artifactRoot) {
await page.screenshot({ path: resolve(artifactRoot, 'dynasty-core-detail.png'), fullPage: true });
}
});
test('dynasty detail preserves the legacy fields, old-nation table and error flow', async ({ page }) => {
await page.goto(`${gameOrigin}/che/dynasty/1`);
await expect(page.getByText('장 수 성 격')).toBeVisible();
await expect(page.getByText('의리(14), 대담(11)')).toBeVisible();
await expect(page.getByText('오 호 장 군')).toBeVisible();
await expect(page.getByText('건 안 칠 자')).toBeVisible();
await expect(page.getByText('【 백년01 】')).toBeVisible();
await expect(page.getByText('황제', { exact: true })).toBeVisible();
await expect(page.locator('.old-nation-table')).toHaveCount(1);
const geometry = await page.locator('#dynasty-detail-container').evaluate((container) => {
const rect = container.getBoundingClientRect();
const first = container.querySelector<HTMLTableElement>('table')!.getBoundingClientRect();
const emperor = container.querySelector<HTMLTableElement>('.emperor-table')!.getBoundingClientRect();
const oldNation = container.querySelector<HTMLTableElement>('.old-nation-table')!.getBoundingClientRect();
return {
container: { x: rect.x, y: rect.y, width: rect.width },
first: { x: first.x, y: first.y, width: first.width, height: first.height },
emperor: { x: emperor.x, y: emperor.y, width: emperor.width },
oldNation: { x: oldNation.x, width: oldNation.width },
};
});
expect(geometry.container).toEqual({ x: 140, y: 8, width: 1000 });
expect(geometry.first).toEqual({ x: 140, y: 8, width: 1000, height: 47 });
expect(geometry.emperor).toEqual({ x: 140, y: 55, width: 1000 });
expect(geometry.oldNation).toEqual({ x: 140, width: 1000 });
await page.goto(`${gameOrigin}/che/dynasty/999`);
await expect(page.getByRole('alert')).toHaveText('왕조 정보를 찾을 수 없습니다.');
await expect(page.locator('.emperor-table')).toHaveCount(0);
await page.getByRole('button', { name: '창 닫기' }).first().click();
await expect(page).toHaveURL(/\/che\/public$/);
});
test('dynasty public view is identical for anonymous and distinct user tokens', async ({ browser }) => {
const snapshots: string[] = [];
for (const token of [null, 'ga_dynasty_owner_a', 'ga_dynasty_owner_b']) {
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
deviceScaleFactor: 1,
colorScheme: 'dark',
});
const page = await context.newPage();
await installFixture(page);
if (token) {
await page.addInitScript((value) => {
window.localStorage.setItem('sammo-game-token', value);
}, token);
}
await page.goto(`${gameOrigin}/che/dynasty`);
await expect(page.getByText('백년01 (215年 4月)')).toBeVisible();
snapshots.push(await page.locator('#dynasty-list-container').innerText());
await context.close();
}
expect(snapshots[1]).toBe(snapshots[0]);
expect(snapshots[2]).toBe(snapshots[0]);
});
@@ -0,0 +1,93 @@
import { chromium } from '@playwright/test';
const baseUrl = process.env.DYNASTY_REF_URL ?? 'http://127.0.0.1:3410/hwe';
const detailId = process.env.DYNASTY_REF_DETAIL_ID ?? '1';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({
viewport: { width: 1280, height: 900 },
deviceScaleFactor: 1,
colorScheme: 'dark',
locale: 'ko-KR',
});
const measure = async (url) => {
await page.goto(url, { waitUntil: 'networkidle' });
return page.evaluate(() => {
const tables = Array.from(document.querySelectorAll('table')).map((table) => {
const rect = table.getBoundingClientRect();
const style = getComputedStyle(table);
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
backgroundImage: style.backgroundImage,
borderSpacing: style.borderSpacing,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
};
});
const button = document.querySelector('button');
const buttonStyle = button ? getComputedStyle(button) : null;
const firstCell = document.querySelector('td');
const firstCellStyle = firstCell ? getComputedStyle(firstCell) : null;
return {
chromium: navigator.userAgent,
body: {
width: document.body.getBoundingClientRect().width,
minWidth: getComputedStyle(document.body).minWidth,
margin: getComputedStyle(document.body).margin,
fontFamily: getComputedStyle(document.body).fontFamily,
fontSize: getComputedStyle(document.body).fontSize,
lineHeight: getComputedStyle(document.body).lineHeight,
},
tables,
button: buttonStyle
? {
height: button.getBoundingClientRect().height,
borderWidth: buttonStyle.borderWidth,
borderRadius: buttonStyle.borderRadius,
backgroundColor: buttonStyle.backgroundColor,
color: buttonStyle.color,
padding: buttonStyle.padding,
fontFamily: buttonStyle.fontFamily,
fontSize: buttonStyle.fontSize,
lineHeight: buttonStyle.lineHeight,
cursor: buttonStyle.cursor,
}
: null,
firstCell: firstCellStyle
? {
padding: firstCellStyle.padding,
borderWidth: firstCellStyle.borderWidth,
textAlign: firstCellStyle.textAlign,
}
: null,
};
});
};
const output = {
list: await measure(`${baseUrl}/a_emperior.php`),
detail: await measure(`${baseUrl}/a_emperior_detail.php?select=${detailId}`),
};
if (process.env.FRONTEND_PARITY_ARTIFACT_DIR) {
await page.goto(`${baseUrl}/a_emperior.php`, { waitUntil: 'networkidle' });
await page.screenshot({
path: `${process.env.FRONTEND_PARITY_ARTIFACT_DIR}/dynasty-ref-list.png`,
fullPage: true,
});
if (process.env.DYNASTY_REF_CAPTURE_DETAIL === '1') {
await page.goto(`${baseUrl}/a_emperior_detail.php?select=${detailId}`, { waitUntil: 'networkidle' });
await page.screenshot({
path: `${process.env.FRONTEND_PARITY_ARTIFACT_DIR}/dynasty-ref-detail.png`,
fullPage: true,
});
}
}
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
await browser.close();
@@ -5,6 +5,7 @@ import { defineConfig, devices } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const gatewayPort = process.env.FRONTEND_PARITY_GATEWAY_PORT ?? '15100';
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
const reuseExistingServer = process.env.FRONTEND_PARITY_REUSE_SERVER === '1';
export default defineConfig({
testDir: '.',
@@ -13,6 +14,7 @@ export default defineConfig({
'public-gaps.spec.ts',
'instant-diplomacy-message.spec.ts',
'tournament-betting.spec.ts',
'dynasty-parity.spec.ts',
],
fullyParallel: false,
workers: 1,
@@ -39,14 +41,14 @@ export default defineConfig({
command: `VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_API_URL_TEMPLATE=/{profile}/api/trpc VITE_GAME_ASSET_URL=/image pnpm --filter @sammo-ts/gateway-frontend dev --host 127.0.0.1 --port ${gatewayPort}`,
cwd: repositoryRoot,
url: `http://127.0.0.1:${gatewayPort}/gateway/`,
reuseExistingServer: false,
reuseExistingServer,
timeout: 120_000,
},
{
command: `VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc VITE_GAME_ASSET_URL=/image VITE_GAME_PROFILE=che VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${gamePort}`,
cwd: repositoryRoot,
url: `http://127.0.0.1:${gamePort}/che/`,
reuseExistingServer: false,
reuseExistingServer,
timeout: 120_000,
},
],