feat: add dynasty management features including list and detail views

- Implemented `OldNation`, `OldGeneral`, and `Emperor` models in Prisma schema.
- Created API routes for fetching dynasty lists and details.
- Developed frontend views for displaying dynasty list and detail information.
- Enhanced in-memory world to track deleted nation snapshots during unification.
- Added functionality to settle dynasty information after unification events.
This commit is contained in:
2026-01-29 19:09:17 +00:00
parent c4a5361fc6
commit d3277f204a
12 changed files with 1095 additions and 1 deletions
+2
View File
@@ -21,6 +21,7 @@ import { boardRouter } from './router/board/index.js';
import { diplomacyRouter } from './router/diplomacy/index.js';
import { yearbookRouter } from './router/yearbook/index.js';
import { rankingRouter } from './router/ranking/index.js';
import { dynastyRouter } from './router/dynasty/index.js';
export const appRouter = router({
health: healthRouter,
@@ -44,6 +45,7 @@ export const appRouter = router({
diplomacy: diplomacyRouter,
yearbook: yearbookRouter,
ranking: rankingRouter,
dynasty: dynastyRouter,
});
export type AppRouter = typeof appRouter;
+139
View File
@@ -0,0 +1,139 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { router, procedure } 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') : [];
export const dynastyRouter = router({
getList: procedure.query(async ({ ctx }) => {
const rows = await ctx.db.emperor.findMany({
orderBy: { id: 'desc' },
});
return {
entries: rows.map((row) => ({
id: row.id,
serverId: row.serverId ?? '',
phase: row.phase ?? '',
name: row.name ?? '',
year: row.year ?? 0,
month: row.month ?? 0,
color: row.color ?? '#000000',
type: row.type ?? '',
power: row.power ?? 0,
gennum: row.gennum ?? 0,
citynum: row.citynum ?? 0,
})),
};
}),
getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => {
const emperor = await ctx.db.emperor.findFirst({
where: { id: input.emperorId },
});
if (!emperor) {
throw new TRPCError({ code: 'NOT_FOUND', message: '왕조 정보를 찾을 수 없습니다.' });
}
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 },
orderBy: { date: 'desc' },
});
const nationEntries = oldNationRows
.map((row) => {
const data = asRecord(row.data);
const nationId = row.nation ?? (typeof data.nation === 'number' ? data.nation : 0);
return {
nation: nationId,
isWinner: winnerNationId !== null && nationId === winnerNationId,
name: typeof data.name === 'string' ? data.name : row.nation === 0 ? '재야' : '미상',
color: typeof data.color === 'string' ? data.color : '#000000',
type: typeof data.type === 'string' ? data.type : '',
level: typeof data.level === 'number' ? data.level : null,
tech: typeof data.tech === 'number' ? data.tech : null,
maxPower: typeof data.maxPower === 'number' ? data.maxPower : null,
maxCrew: typeof data.maxCrew === 'number' ? data.maxCrew : null,
maxCities: Array.isArray(data.maxCities) ? data.maxCities : [],
generals: parseNumberArray(data.generals),
history: Array.isArray(data.history) ? data.history : [],
date: row.date.toISOString(),
};
})
.filter((entry) => entry.nation !== 0);
const generalIds = Array.from(new Set(nationEntries.flatMap((entry) => entry.generals)));
const generalRows = generalIds.length
? await ctx.db.oldGeneral.findMany({
where: {
serverId,
generalNo: { in: generalIds },
},
select: { generalNo: true, name: true, lastYearMonth: true },
})
: [];
const generalMap = new Map<number, { name: string; lastYearMonth: number }>();
for (const row of generalRows) {
generalMap.set(row.generalNo, { name: row.name, lastYearMonth: row.lastYearMonth });
}
const nations = nationEntries.map((entry) => ({
...entry,
generalsFull: entry.generals.map((id) => ({
generalNo: id,
name: generalMap.get(id)?.name ?? `#${id}`,
lastYearMonth: generalMap.get(id)?.lastYearMonth ?? null,
})),
}));
return {
emperor: {
id: emperor.id,
serverId,
winnerNationId,
phase: emperor.phase ?? '',
nationCount: emperor.nationCount ?? '',
nationName: emperor.nationName ?? '',
nationHist: emperor.nationHist ?? '',
genCount: emperor.genCount ?? '',
personalHist: emperor.personalHist ?? '',
specialHist: emperor.specialHist ?? '',
name: emperor.name ?? '',
type: emperor.type ?? '',
color: emperor.color ?? '#000000',
year: emperor.year ?? 0,
month: emperor.month ?? 0,
power: emperor.power ?? 0,
gennum: emperor.gennum ?? 0,
citynum: emperor.citynum ?? 0,
pop: emperor.pop ?? '0',
poprate: emperor.poprate ?? '',
gold: emperor.gold ?? 0,
rice: emperor.rice ?? 0,
l12name: emperor.l12name ?? '',
l11name: emperor.l11name ?? '',
l10name: emperor.l10name ?? '',
l9name: emperor.l9name ?? '',
l8name: emperor.l8name ?? '',
l7name: emperor.l7name ?? '',
l6name: emperor.l6name ?? '',
l5name: emperor.l5name ?? '',
tiger: emperor.tiger ?? '',
eagle: emperor.eagle ?? '',
gen: emperor.gen ?? '',
history: Array.isArray(emperor.history) ? emperor.history : [],
},
nations,
};
}),
});
+73 -1
View File
@@ -12,7 +12,7 @@ import {
type TurnEngineTroopUpdateInput,
type TurnEngineWorldStateUpdateInput,
} from '@sammo-ts/infra';
import { finalizeLogEntry, type LogEntryDraft } from '@sammo-ts/logic';
import { finalizeLogEntry, LogCategory, LogScope, type LogEntryDraft } from '@sammo-ts/logic';
import { asRecord, type RankDataType } from '@sammo-ts/common';
import type { TurnDaemonHooks } from '../lifecycle/types.js';
@@ -316,6 +316,7 @@ export const createDatabaseTurnHooks = async (
deletedTroops,
deletedGenerals,
deletedNations,
deletedNationSnapshots,
diplomacy,
logs,
createdGenerals,
@@ -335,6 +336,77 @@ export const createDatabaseTurnHooks = async (
data: worldStateUpdate,
});
const meta = asRecord(state.meta);
const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default';
if (deletedNationSnapshots.length > 0) {
const nationIds = deletedNationSnapshots.map((snapshot) => snapshot.nation.id);
const historyRows = await prisma.logEntry.findMany({
where: {
nationId: { in: nationIds },
scope: LogScope.NATION,
category: LogCategory.HISTORY,
},
orderBy: { id: 'asc' },
select: { nationId: true, text: true },
});
const historyMap = new Map<number, string[]>();
for (const row of historyRows) {
const bucket = historyMap.get(row.nationId ?? 0) ?? [];
bucket.push(row.text);
historyMap.set(row.nationId ?? 0, bucket);
}
await Promise.all(
deletedNationSnapshots.map((snapshot) =>
prisma.oldNation.upsert({
where: {
serverId_nation: {
serverId,
nation: snapshot.nation.id,
},
},
update: {
data: {
nation: snapshot.nation.id,
name: snapshot.nation.name,
color: snapshot.nation.color,
type: snapshot.nation.typeCode,
level: snapshot.nation.level,
gold: snapshot.nation.gold,
rice: snapshot.nation.rice,
power: snapshot.nation.power,
capitalCityId: snapshot.nation.capitalCityId,
generals: snapshot.generalIds,
history: historyMap.get(snapshot.nation.id) ?? [],
meta: snapshot.nation.meta ?? {},
},
date: snapshot.removedAt,
},
create: {
serverId,
nation: snapshot.nation.id,
data: {
nation: snapshot.nation.id,
name: snapshot.nation.name,
color: snapshot.nation.color,
type: snapshot.nation.typeCode,
level: snapshot.nation.level,
gold: snapshot.nation.gold,
rice: snapshot.nation.rice,
power: snapshot.nation.power,
capitalCityId: snapshot.nation.capitalCityId,
generals: snapshot.generalIds,
history: historyMap.get(snapshot.nation.id) ?? [],
meta: snapshot.nation.meta ?? {},
},
date: snapshot.removedAt,
},
})
)
);
}
const createdIds = new Set(createdGenerals.map((general) => general.id));
const createdNationIds = new Set(createdNations.map((nation) => nation.id));
const createdTroopIds = new Set(createdTroops.map((troop) => troop.id));
+19
View File
@@ -224,6 +224,11 @@ export class InMemoryTurnWorld {
private readonly deletedTroopIds = new Set<number>();
private readonly deletedGeneralIds = new Set<number>();
private readonly deletedNationIds = new Set<number>();
private readonly deletedNationSnapshots: Array<{
nation: Nation;
generalIds: number[];
removedAt: Date;
}> = [];
private readonly logs: LogEntryDraft[] = [];
private readonly scenarioConfig: ScenarioConfig;
private checkpoint?: TurnCheckpoint;
@@ -697,6 +702,7 @@ export class InMemoryTurnWorld {
deletedTroops: number[];
deletedGenerals: number[];
deletedNations: number[];
deletedNationSnapshots: Array<{ nation: Nation; generalIds: number[]; removedAt: Date }>;
diplomacy: TurnDiplomacy[];
logs: LogEntryDraft[];
createdGenerals: TurnGeneral[];
@@ -734,6 +740,7 @@ export class InMemoryTurnWorld {
const deletedTroops = Array.from(this.deletedTroopIds);
const deletedGenerals = Array.from(this.deletedGeneralIds);
const deletedNations = Array.from(this.deletedNationIds);
const deletedNationSnapshots = this.deletedNationSnapshots.splice(0, this.deletedNationSnapshots.length);
const logs = this.logs.splice(0, this.logs.length);
this.dirtyGeneralIds.clear();
@@ -757,6 +764,7 @@ export class InMemoryTurnWorld {
deletedTroops,
deletedGenerals,
deletedNations,
deletedNationSnapshots,
diplomacy,
logs,
createdGenerals,
@@ -784,6 +792,17 @@ export class InMemoryTurnWorld {
}
for (const nationId of collapsedNationIds) {
const nation = this.nations.get(nationId);
if (nation) {
const generalIds = Array.from(this.generals.values())
.filter((general) => general.nationId === nationId)
.map((general) => general.id);
this.deletedNationSnapshots.push({
nation: { ...nation },
generalIds,
removedAt: new Date(this.state.lastTurnTime.getTime()),
});
}
for (const general of this.generals.values()) {
if (general.nationId !== nationId) {
continue;
@@ -380,6 +380,358 @@ export const createUnificationHandler = (options: {
});
};
const settleDynasty = async (winnerNationId: number): Promise<void> => {
const world = options.getWorld();
if (!world) {
return;
}
await ready;
const prisma = connector.prisma;
const state = world.getState();
const meta = asRecord(state.meta);
const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim()
? meta.serverId.trim()
: options.profileName;
const serverName =
typeof meta.serverName === 'string' && meta.serverName.trim()
? meta.serverName.trim()
: options.profileName;
const [serverCount, nationRows, cityRows, generalRows, rankRows, historyRows, oldNationRows] =
await Promise.all([
prisma.gameHistory.count(),
prisma.nation.findMany({
select: {
id: true,
name: true,
color: true,
level: true,
typeCode: true,
gold: true,
rice: true,
meta: true,
capitalCityId: true,
},
}),
prisma.city.findMany({
select: { nationId: true, population: true, populationMax: true },
}),
prisma.general.findMany({
select: {
id: true,
userId: true,
name: true,
nationId: true,
dedication: true,
officerLevel: true,
picture: true,
imageServer: true,
leadership: true,
strength: true,
intel: true,
experience: true,
gold: true,
rice: true,
crew: true,
crewTypeId: true,
train: true,
atmos: true,
age: true,
startAge: true,
npcState: true,
personalCode: true,
specialCode: true,
special2Code: true,
cityId: true,
troopId: true,
turnTime: true,
meta: true,
},
}),
prisma.rankData.findMany({
where: {
nationId: winnerNationId,
type: { in: ['killnum', 'firenum'] },
},
select: { generalId: true, type: true, value: true },
}),
prisma.logEntry.findMany({
where: {
nationId: winnerNationId,
scope: LogScope.NATION,
category: LogCategory.HISTORY,
},
orderBy: { id: 'asc' },
select: { text: true },
}),
prisma.oldNation.findMany({
where: { serverId },
select: { data: true },
}),
]);
const winnerNation = nationRows.find((nation) => nation.id === winnerNationId);
if (!winnerNation) {
return;
}
const powerMap = new Map<number, number>();
for (const nation of world.listNations()) {
powerMap.set(nation.id, nation.power);
}
const winnerGenerals = generalRows.filter((general) => general.nationId === winnerNationId);
const winnerGeneralIds = winnerGenerals.map((general) => general.id);
const noNationGenerals = generalRows.filter((general) => general.nationId === 0);
const cityCount = cityRows.filter((city) => city.nationId === winnerNationId).length;
const popSum = cityRows
.filter((city) => city.nationId === winnerNationId)
.reduce((sum, city) => sum + city.population, 0);
const popMaxSum = cityRows
.filter((city) => city.nationId === winnerNationId)
.reduce((sum, city) => sum + city.populationMax, 0);
const popText = `${popSum} / ${popMaxSum}`;
const popRate = popMaxSum > 0 ? `${Math.round((popSum / popMaxSum) * 10000) / 100} %` : '0 %';
const officerMap = new Map<number, { name: string; picture: string | null }>();
for (const general of winnerGenerals) {
if (general.officerLevel < 5) {
continue;
}
if (!officerMap.has(general.officerLevel)) {
officerMap.set(general.officerLevel, { name: general.name, picture: general.picture ?? null });
}
}
const generalNameMap = new Map<number, string>();
for (const general of generalRows) {
generalNameMap.set(general.id, general.name);
}
const buildTopList = (type: 'killnum' | 'firenum', limit: number): string => {
const rows = rankRows
.filter((row) => row.type === type && row.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, limit);
return rows
.map((row) => `${generalNameMap.get(row.generalId) ?? '무명'}${row.value.toLocaleString('ko-KR')}`)
.join(', ');
};
const tiger = buildTopList('killnum', 5);
const eagle = buildTopList('firenum', 7);
const gen = winnerGenerals
.slice()
.sort((a, b) => b.dedication - a.dedication)
.map((general) => general.name)
.join(', ');
const nationNames: string[] = [];
const nationTypeCounts = new Map<string, number>();
for (const row of oldNationRows) {
const data = asRecord(row.data);
const name = typeof data.name === 'string' ? data.name : '';
const type = typeof data.type === 'string' ? data.type : '';
if (name) {
nationNames.push(name);
}
if (type) {
nationTypeCounts.set(type, (nationTypeCounts.get(type) ?? 0) + 1);
}
}
if (!nationNames.includes(winnerNation.name)) {
nationNames.push(winnerNation.name);
}
if (winnerNation.typeCode) {
nationTypeCounts.set(winnerNation.typeCode, (nationTypeCounts.get(winnerNation.typeCode) ?? 0) + 1);
}
const nationHist = Array.from(nationTypeCounts.entries())
.map(([key, count]) => `${key}(${count})`)
.join(', ');
const phase = `${serverName}${serverCount}`;
const nationCount = `${Math.max(1, nationRows.length)} / ${Math.max(1, nationRows.length)}`;
const genCount = `${generalRows.length} / ${generalRows.length}`;
const history = historyRows.map((row) => row.text);
await prisma.oldNation.upsert({
where: {
serverId_nation: {
serverId,
nation: winnerNationId,
},
},
update: {
data: {
nation: winnerNationId,
name: winnerNation.name,
color: winnerNation.color,
type: winnerNation.typeCode,
level: winnerNation.level,
gold: winnerNation.gold,
rice: winnerNation.rice,
power: powerMap.get(winnerNationId) ?? 0,
capitalCityId: winnerNation.capitalCityId,
generals: winnerGeneralIds,
history,
meta: winnerNation.meta ?? {},
},
date: new Date(),
},
create: {
serverId,
nation: winnerNationId,
data: {
nation: winnerNationId,
name: winnerNation.name,
color: winnerNation.color,
type: winnerNation.typeCode,
level: winnerNation.level,
gold: winnerNation.gold,
rice: winnerNation.rice,
power: powerMap.get(winnerNationId) ?? 0,
capitalCityId: winnerNation.capitalCityId,
generals: winnerGeneralIds,
history,
meta: winnerNation.meta ?? {},
},
},
});
await prisma.oldNation.upsert({
where: {
serverId_nation: {
serverId,
nation: 0,
},
},
update: {
data: {
nation: 0,
name: '재야',
color: '#000000',
type: 'neutral',
level: 0,
gold: 0,
rice: 0,
power: 0,
capitalCityId: null,
generals: noNationGenerals.map((general) => general.id),
history: [],
meta: {},
},
date: new Date(),
},
create: {
serverId,
nation: 0,
data: {
nation: 0,
name: '재야',
color: '#000000',
type: 'neutral',
level: 0,
gold: 0,
rice: 0,
power: 0,
capitalCityId: null,
generals: noNationGenerals.map((general) => general.id),
history: [],
meta: {},
},
},
});
const oldGeneralTargets = generalRows.filter(
(general) => general.nationId === 0 || general.nationId === winnerNationId
);
await Promise.all(
oldGeneralTargets.map((general) =>
((snapshot) =>
prisma.oldGeneral.upsert({
where: {
by_no: {
serverId,
generalNo: general.id,
},
},
update: {
owner: general.userId ?? null,
name: general.name,
lastYearMonth: state.currentYear * 100 + state.currentMonth,
turnTime: general.turnTime,
data: snapshot,
},
create: {
serverId,
generalNo: general.id,
owner: general.userId ?? null,
name: general.name,
lastYearMonth: state.currentYear * 100 + state.currentMonth,
turnTime: general.turnTime,
data: snapshot,
},
}))( {
...general,
turnTime: general.turnTime.toISOString(),
})
)
);
await prisma.emperor.create({
data: {
serverId,
phase,
nationCount,
nationName: nationNames.join(', '),
nationHist,
genCount,
personalHist: '',
specialHist: '',
name: winnerNation.name,
type: winnerNation.typeCode,
color: winnerNation.color,
year: state.currentYear,
month: state.currentMonth,
power: powerMap.get(winnerNationId) ?? 0,
gennum: winnerGenerals.length,
citynum: cityCount,
pop: popText,
poprate: popRate,
gold: winnerNation.gold,
rice: winnerNation.rice,
l12name: officerMap.get(12)?.name ?? '',
l12pic: officerMap.get(12)?.picture ?? '',
l11name: officerMap.get(11)?.name ?? '',
l11pic: officerMap.get(11)?.picture ?? '',
l10name: officerMap.get(10)?.name ?? '',
l10pic: officerMap.get(10)?.picture ?? '',
l9name: officerMap.get(9)?.name ?? '',
l9pic: officerMap.get(9)?.picture ?? '',
l8name: officerMap.get(8)?.name ?? '',
l8pic: officerMap.get(8)?.picture ?? '',
l7name: officerMap.get(7)?.name ?? '',
l7pic: officerMap.get(7)?.picture ?? '',
l6name: officerMap.get(6)?.name ?? '',
l6pic: officerMap.get(6)?.picture ?? '',
l5name: officerMap.get(5)?.name ?? '',
l5pic: officerMap.get(5)?.picture ?? '',
tiger,
eagle,
gen,
history,
aux: {
winnerNationId,
},
},
});
};
const handler: TurnCalendarHandler = {
onMonthChanged: (context) => {
const world = options.getWorld();
@@ -407,6 +759,7 @@ export const createUnificationHandler = (options: {
world.pushLog(buildUnificationLog(winner.name));
void settleInheritance(winner.id, context.currentYear, context.currentMonth);
void settleHallOfFame(winner.id);
void settleDynasty(winner.id);
},
};
+12
View File
@@ -22,6 +22,8 @@ import ScoutMessageView from '../views/ScoutMessageView.vue';
import DiplomacyView from '../views/DiplomacyView.vue';
import BestGeneralView from '../views/BestGeneralView.vue';
import HallOfFameView from '../views/HallOfFameView.vue';
import DynastyListView from '../views/DynastyListView.vue';
import DynastyDetailView from '../views/DynastyDetailView.vue';
import { useSessionStore } from '../stores/session';
const routes = [
@@ -178,6 +180,16 @@ const routes = [
name: 'hall-of-fame',
component: HallOfFameView,
},
{
path: '/dynasty',
name: 'dynasty-list',
component: DynastyListView,
},
{
path: '/dynasty/:id',
name: 'dynasty-detail',
component: DynastyDetailView,
},
{
path: '/my-page',
name: 'my-page',
@@ -0,0 +1,262 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
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[];
};
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const errorMessage = ref('');
const data = ref<DynastyDetailPayload | null>(null);
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;
});
const dynastyApi = trpc as unknown as {
dynasty: {
getDetail: {
query: (input: { emperorId: number }) => Promise<DynastyDetailPayload>;
};
};
};
const loadDetail = async () => {
if (emperorId.value === null) {
errorMessage.value = '잘못된 왕조 번호입니다.';
return;
}
loading.value = true;
errorMessage.value = '';
try {
data.value = await dynastyApi.dynasty.getDetail.query({
emperorId: emperorId.value,
});
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '왕조 정보를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
watch(emperorId, () => {
void loadDetail();
});
onMounted(async () => {
await 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>
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
<div v-else-if="loading" class="placeholder">불러오는 중...</div>
<div v-else-if="!data" class="placeholder">표시할 데이터가 없습니다.</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>
<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>
<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>
</main>
</template>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
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[];
};
const loading = ref(false);
const errorMessage = ref('');
const entries = ref<DynastyEntry[]>([]);
const loadDynasty = async () => {
loading.value = true;
errorMessage.value = '';
try {
const result = (await trpc.dynasty.getList.query()) as DynastyListPayload;
entries.value = result.entries;
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '왕조일람을 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
onMounted(async () => {
await 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>
<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>
<section v-else class="grid gap-4">
<RouterLink
v-for="entry in 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}`"
>
<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>
</main>
</template>
+1
View File
@@ -100,6 +100,7 @@ watch(
<RouterLink class="ghost" to="/battle-center">감찰부</RouterLink>
<RouterLink class="ghost" to="/best-general">명장일람</RouterLink>
<RouterLink class="ghost" to="/hall-of-fame">명예의 전당</RouterLink>
<RouterLink class="ghost" to="/dynasty">왕조일람</RouterLink>
<a class="ghost" href="/xe/community" target="_blank" rel="noopener">게시판</a>
<RouterLink class="ghost" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<RouterLink class="ghost" to="/my-page"> 정보</RouterLink>
+75
View File
@@ -193,6 +193,81 @@ model GameHistory {
@@map("ng_games")
}
model OldNation {
id Int @id @default(autoincrement())
serverId String @map("server_id")
nation Int @default(0)
data Json @default(dbgenerated("'{}'::jsonb"))
date DateTime @default(now())
@@unique([serverId, nation])
@@index([serverId, nation], name: "by_server")
@@map("ng_old_nations")
}
model OldGeneral {
id Int @id @default(autoincrement())
serverId String @map("server_id")
generalNo Int @map("general_no")
owner String? @map("owner")
name String
lastYearMonth Int @map("last_yearmonth")
turnTime DateTime @map("turntime")
data Json @default(dbgenerated("'{}'::jsonb"))
@@unique([serverId, generalNo], name: "by_no")
@@index([serverId, name], name: "by_name")
@@index([owner, serverId], name: "owner")
@@map("ng_old_generals")
}
model Emperor {
id Int @id @default(autoincrement()) @map("no")
serverId String? @map("server_id")
phase String? @default("")
nationCount String? @map("nation_count")
nationName String? @map("nation_name")
nationHist String? @map("nation_hist")
genCount String? @map("gen_count")
personalHist String? @map("personal_hist")
specialHist String? @map("special_hist")
name String? @default("")
type String? @default("")
color String? @default("")
year Int? @default(0)
month Int? @default(0)
power Int? @default(0)
gennum Int? @default(0)
citynum Int? @default(0)
pop String? @default("0")
poprate String? @default("")
gold Int? @default(0)
rice Int? @default(0)
l12name String? @default("")
l12pic String? @default("")
l11name String? @default("")
l11pic String? @default("")
l10name String? @default("")
l10pic String? @default("")
l9name String? @default("")
l9pic String? @default("")
l8name String? @default("")
l8pic String? @default("")
l7name String? @default("")
l7pic String? @default("")
l6name String? @default("")
l6pic String? @default("")
l5name String? @default("")
l5pic String? @default("")
tiger String? @default("")
eagle String? @default("")
gen String? @default("")
history Json @default(dbgenerated("'{}'::jsonb"))
aux Json @default(dbgenerated("'{}'::jsonb"))
@@map("emperior")
}
model Troop {
troopLeaderId Int @id @map("troop_leader")
nationId Int @map("nation")
@@ -0,0 +1,70 @@
CREATE TABLE IF NOT EXISTS "ng_old_nations" (
"id" SERIAL PRIMARY KEY,
"server_id" VARCHAR(20) NOT NULL DEFAULT '0',
"nation" INT NOT NULL DEFAULT 0,
"data" JSONB NOT NULL DEFAULT '{}'::jsonb,
"date" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS "ng_old_nations_server_id_nation" ON "ng_old_nations" ("server_id", "nation");
CREATE INDEX IF NOT EXISTS "ng_old_nations_by_server" ON "ng_old_nations" ("server_id", "nation");
CREATE TABLE IF NOT EXISTS "ng_old_generals" (
"id" SERIAL PRIMARY KEY,
"server_id" VARCHAR(20) NOT NULL,
"general_no" INT NOT NULL,
"owner" TEXT NULL,
"name" VARCHAR(32) NOT NULL,
"last_yearmonth" INT NOT NULL,
"turntime" TIMESTAMP NOT NULL,
"data" JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE UNIQUE INDEX IF NOT EXISTS "ng_old_generals_by_no" ON "ng_old_generals" ("server_id", "general_no");
CREATE INDEX IF NOT EXISTS "ng_old_generals_by_name" ON "ng_old_generals" ("server_id", "name");
CREATE INDEX IF NOT EXISTS "ng_old_generals_owner" ON "ng_old_generals" ("owner", "server_id");
CREATE TABLE IF NOT EXISTS "emperior" (
"no" SERIAL PRIMARY KEY,
"server_id" VARCHAR(20) NULL DEFAULT '',
"phase" VARCHAR(255) NULL DEFAULT '',
"nation_count" VARCHAR(64) NULL DEFAULT '',
"nation_name" TEXT NULL DEFAULT '',
"nation_hist" TEXT NULL DEFAULT '',
"gen_count" VARCHAR(64) NULL DEFAULT '',
"personal_hist" TEXT NULL DEFAULT '',
"special_hist" TEXT NULL DEFAULT '',
"name" VARCHAR(64) NULL DEFAULT '',
"type" VARCHAR(64) NULL DEFAULT '',
"color" VARCHAR(7) NULL DEFAULT '',
"year" INT NULL DEFAULT 0,
"month" INT NULL DEFAULT 0,
"power" INT NULL DEFAULT 0,
"gennum" INT NULL DEFAULT 0,
"citynum" INT NULL DEFAULT 0,
"pop" VARCHAR(255) NULL DEFAULT '0',
"poprate" VARCHAR(255) NULL DEFAULT '',
"gold" INT NULL DEFAULT 0,
"rice" INT NULL DEFAULT 0,
"l12name" VARCHAR(64) NULL DEFAULT '',
"l12pic" VARCHAR(32) NULL DEFAULT '',
"l11name" VARCHAR(64) NULL DEFAULT '',
"l11pic" VARCHAR(32) NULL DEFAULT '',
"l10name" VARCHAR(64) NULL DEFAULT '',
"l10pic" VARCHAR(32) NULL DEFAULT '',
"l9name" VARCHAR(64) NULL DEFAULT '',
"l9pic" VARCHAR(32) NULL DEFAULT '',
"l8name" VARCHAR(64) NULL DEFAULT '',
"l8pic" VARCHAR(32) NULL DEFAULT '',
"l7name" VARCHAR(64) NULL DEFAULT '',
"l7pic" VARCHAR(32) NULL DEFAULT '',
"l6name" VARCHAR(64) NULL DEFAULT '',
"l6pic" VARCHAR(32) NULL DEFAULT '',
"l5name" VARCHAR(64) NULL DEFAULT '',
"l5pic" VARCHAR(32) NULL DEFAULT '',
"tiger" VARCHAR(128) NULL DEFAULT '',
"eagle" VARCHAR(128) NULL DEFAULT '',
"gen" TEXT NULL DEFAULT '',
"history" JSONB NOT NULL DEFAULT '{}'::jsonb,
"aux" JSONB NOT NULL DEFAULT '{}'::jsonb
);
+3
View File
@@ -14,6 +14,9 @@ export interface DatabaseClient {
rankData: GamePrisma.RankDataDelegate;
hallOfFame: GamePrisma.HallOfFameDelegate;
gameHistory: GamePrisma.GameHistoryDelegate;
oldNation: GamePrisma.OldNationDelegate;
oldGeneral: GamePrisma.OldGeneralDelegate;
emperor: GamePrisma.EmperorDelegate;
generalTurn: GamePrisma.GeneralTurnDelegate;
nationTurn: GamePrisma.NationTurnDelegate;
troop: GamePrisma.TroopDelegate;