feat: add ranking and hall of fame features
- Implemented new routes for Best General and Hall of Fame views in the frontend. - Created BestGeneralView and HallOfFameView components to display rankings based on game data. - Added new database models for RankData and HallOfFame to store ranking information. - Introduced ranking types and hall of fame types in common types for better type safety. - Developed ranking router to handle fetching of best general and hall of fame data. - Updated database schema with migrations to include new tables for rank data and hall of fame. - Enhanced the main view with links to the new ranking features.
This commit is contained in:
@@ -20,6 +20,7 @@ import { tournamentRouter } from './router/tournament/index.js';
|
||||
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';
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
@@ -42,6 +43,7 @@ export const appRouter = router({
|
||||
board: boardRouter,
|
||||
diplomacy: diplomacyRouter,
|
||||
yearbook: yearbookRouter,
|
||||
ranking: rankingRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common';
|
||||
import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/index.js';
|
||||
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
|
||||
const DEFAULT_BG_COLOR = '#2b2b2b';
|
||||
const DEFAULT_FG_COLOR = '#ffffff';
|
||||
|
||||
const readMetaNumber = (value: unknown): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`;
|
||||
|
||||
const itemLoader = new ItemLoader();
|
||||
let cachedUniqueItems: Promise<
|
||||
Array<{ key: string; name: string; slot: string; unique: boolean; buyable: boolean; info: string }>
|
||||
> | null = null;
|
||||
|
||||
const loadUniqueItems = () => {
|
||||
if (!cachedUniqueItems) {
|
||||
cachedUniqueItems = loadItemModules([...ITEM_KEYS], itemLoader).then((modules) =>
|
||||
modules
|
||||
.filter((module) => module.unique && !module.buyable)
|
||||
.map((module) => ({
|
||||
key: module.key,
|
||||
name: module.name,
|
||||
slot: module.slot,
|
||||
unique: module.unique,
|
||||
buyable: module.buyable,
|
||||
info: module.info,
|
||||
}))
|
||||
);
|
||||
}
|
||||
return cachedUniqueItems;
|
||||
};
|
||||
|
||||
export const rankingRouter = router({
|
||||
getBestGeneral: procedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
view: z.enum(['user', 'npc']).optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst({
|
||||
select: { meta: true },
|
||||
});
|
||||
const meta = asRecord(worldState?.meta);
|
||||
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
|
||||
|
||||
const view = input?.view ?? 'user';
|
||||
const npcFilter = view === 'npc' ? { gte: 2 } : { lt: 2 };
|
||||
|
||||
const [nations, generals] = await Promise.all([
|
||||
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
|
||||
ctx.db.general.findMany({
|
||||
where: { npcState: npcFilter },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nationId: true,
|
||||
userId: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
horseCode: true,
|
||||
weaponCode: true,
|
||||
bookCode: true,
|
||||
itemCode: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
|
||||
const generalIds = generals.map((general) => general.id);
|
||||
const rankRows = await ctx.db.rankData.findMany({
|
||||
where: { generalId: { in: generalIds } },
|
||||
select: { generalId: true, type: true, value: true },
|
||||
});
|
||||
const rankMap = new Map<number, Record<string, number>>();
|
||||
for (const row of rankRows) {
|
||||
const entry = rankMap.get(row.generalId) ?? {};
|
||||
entry[row.type] = row.value;
|
||||
rankMap.set(row.generalId, entry);
|
||||
}
|
||||
|
||||
const types: Array<[string, 'int' | 'percent', (general: typeof generals[number], ranks: Record<string, number>) => number]> = [
|
||||
['명 성', 'int', (g) => g.experience],
|
||||
['계 급', 'int', (g) => g.dedication],
|
||||
['계 략 성 공', 'int', (_g, r) => r.firenum ?? 0],
|
||||
['전 투 횟 수', 'int', (_g, r) => r.warnum ?? 0],
|
||||
['승 리', 'int', (_g, r) => r.killnum ?? 0],
|
||||
['승 률', 'percent', (_g, r) => {
|
||||
const warnum = r.warnum ?? 0;
|
||||
if (warnum < 10) {
|
||||
return 0;
|
||||
}
|
||||
return (r.killnum ?? 0) / Math.max(1, warnum);
|
||||
}],
|
||||
['점 령', 'int', (_g, r) => r.occupied ?? 0],
|
||||
['사 살', 'int', (_g, r) => r.killcrew ?? 0],
|
||||
['살 상 률', 'percent', (_g, r) => {
|
||||
const warnum = r.warnum ?? 0;
|
||||
if (warnum < 10) {
|
||||
return 0;
|
||||
}
|
||||
return (r.killcrew ?? 0) / Math.max(1, r.deathcrew ?? 0);
|
||||
}],
|
||||
['대 인 사 살', 'int', (_g, r) => r.killcrew_person ?? 0],
|
||||
['대 인 살 상 률', 'percent', (_g, r) => {
|
||||
const warnum = r.warnum ?? 0;
|
||||
if (warnum < 10) {
|
||||
return 0;
|
||||
}
|
||||
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
|
||||
}],
|
||||
['보 병 숙 련 도', 'int', (_g, r) => r.dex1 ?? 0],
|
||||
['궁 병 숙 련 도', 'int', (_g, r) => r.dex2 ?? 0],
|
||||
['기 병 숙 련 도', 'int', (_g, r) => r.dex3 ?? 0],
|
||||
['귀 병 숙 련 도', 'int', (_g, r) => r.dex4 ?? 0],
|
||||
['차 병 숙 련 도', 'int', (_g, r) => r.dex5 ?? 0],
|
||||
['전 력 전 승 률', 'percent', (_g, r) => {
|
||||
const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0);
|
||||
if (total < 50) {
|
||||
return 0;
|
||||
}
|
||||
return (r.ttw ?? 0) / Math.max(1, total);
|
||||
}],
|
||||
['통 솔 전 승 률', 'percent', (_g, r) => {
|
||||
const total = (r.tlw ?? 0) + (r.tld ?? 0) + (r.tll ?? 0);
|
||||
if (total < 50) {
|
||||
return 0;
|
||||
}
|
||||
return (r.tlw ?? 0) / Math.max(1, total);
|
||||
}],
|
||||
['일 기 토 승 률', 'percent', (_g, r) => {
|
||||
const total = (r.tsw ?? 0) + (r.tsd ?? 0) + (r.tsl ?? 0);
|
||||
if (total < 50) {
|
||||
return 0;
|
||||
}
|
||||
return (r.tsw ?? 0) / Math.max(1, total);
|
||||
}],
|
||||
['설 전 승 률', 'percent', (_g, r) => {
|
||||
const total = (r.tiw ?? 0) + (r.tid ?? 0) + (r.til ?? 0);
|
||||
if (total < 50) {
|
||||
return 0;
|
||||
}
|
||||
return (r.tiw ?? 0) / Math.max(1, total);
|
||||
}],
|
||||
['베 팅 투 자 액', 'int', (_g, r) => r.betgold ?? 0],
|
||||
['베 팅 당 첨', 'int', (_g, r) => r.betwin ?? 0],
|
||||
['베 팅 수 익 금', 'int', (_g, r) => r.betwingold ?? 0],
|
||||
['베 팅 수 익 률', 'percent', (_g, r) => {
|
||||
const betgold = r.betgold ?? 0;
|
||||
if (betgold < 1000) {
|
||||
return 0;
|
||||
}
|
||||
return (r.betwingold ?? 0) / Math.max(1, betgold);
|
||||
}],
|
||||
['유 산 소 모 량', 'int', (_g, r) => r.inherit_spent ?? 0],
|
||||
['유 산 획 득 량', 'int', (_g, r) => r.inherit_earned ?? 0],
|
||||
];
|
||||
|
||||
const sections = types.map(([title, valueType, valueFn]) => {
|
||||
const entries = generals
|
||||
.map((general) => {
|
||||
const ranks = rankMap.get(general.id) ?? {};
|
||||
const value = valueFn(general, ranks);
|
||||
const nation = nationMap.get(general.nationId) ?? null;
|
||||
let display = {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
ownerName: general.userId ?? null,
|
||||
nationName: nation?.name ?? '재야',
|
||||
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
|
||||
fgColor: DEFAULT_FG_COLOR,
|
||||
picture: general.picture ?? null,
|
||||
imageServer: general.imageServer ?? 0,
|
||||
value,
|
||||
printValue: valueType === 'percent' ? percentText(value) : Math.floor(value).toLocaleString('ko-KR'),
|
||||
};
|
||||
|
||||
if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) {
|
||||
display = {
|
||||
...display,
|
||||
name: '???',
|
||||
ownerName: null,
|
||||
nationName: '???',
|
||||
bgColor: DEFAULT_BG_COLOR,
|
||||
fgColor: DEFAULT_FG_COLOR,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
};
|
||||
}
|
||||
return display;
|
||||
})
|
||||
.filter((entry) => entry.value > 0)
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10);
|
||||
|
||||
return { title, valueType, entries };
|
||||
});
|
||||
|
||||
const uniqueItems = await loadUniqueItems();
|
||||
const itemEntries = uniqueItems.map((item) => {
|
||||
const owners = generals.filter((general) => {
|
||||
if (item.slot === 'horse') {
|
||||
return general.horseCode === item.key;
|
||||
}
|
||||
if (item.slot === 'weapon') {
|
||||
return general.weaponCode === item.key;
|
||||
}
|
||||
if (item.slot === 'book') {
|
||||
return general.bookCode === item.key;
|
||||
}
|
||||
return general.itemCode === item.key;
|
||||
});
|
||||
|
||||
const displayOwners = owners.length
|
||||
? owners.map((general) => {
|
||||
const nation = nationMap.get(general.nationId) ?? null;
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
nationName: nation?.name ?? '재야',
|
||||
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
|
||||
fgColor: DEFAULT_FG_COLOR,
|
||||
};
|
||||
})
|
||||
: [
|
||||
{
|
||||
id: 0,
|
||||
name: '미발견',
|
||||
nationName: '-',
|
||||
bgColor: DEFAULT_BG_COLOR,
|
||||
fgColor: DEFAULT_FG_COLOR,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
title: item.name,
|
||||
slot: item.slot,
|
||||
owners: displayOwners,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
isUnited,
|
||||
sections,
|
||||
uniqueItems: itemEntries,
|
||||
};
|
||||
}),
|
||||
getHallOfFameOptions: procedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db.gameHistory.findMany({
|
||||
select: { season: true, scenario: true, scenarioName: true },
|
||||
orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
|
||||
});
|
||||
const seasonMap = new Map<number, { season: number; scenarios: Array<{ id: number; name: string; count: number }> }>();
|
||||
for (const row of rows) {
|
||||
const entry = seasonMap.get(row.season) ?? { season: row.season, scenarios: [] };
|
||||
const scenario = entry.scenarios.find((item) => item.id === row.scenario);
|
||||
if (scenario) {
|
||||
scenario.count += 1;
|
||||
} else {
|
||||
entry.scenarios.push({ id: row.scenario, name: row.scenarioName, count: 1 });
|
||||
}
|
||||
seasonMap.set(row.season, entry);
|
||||
}
|
||||
return Array.from(seasonMap.values());
|
||||
}),
|
||||
getHallOfFame: procedure
|
||||
.input(
|
||||
z.object({
|
||||
season: z.number().int(),
|
||||
scenario: z.number().int().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const baseWhere = {
|
||||
season: input.season,
|
||||
...(input.scenario !== undefined ? { scenario: input.scenario } : {}),
|
||||
};
|
||||
|
||||
const types: Array<{ key: HallOfFameType; title: string; type: 'int' | 'percent' }> = [
|
||||
{ key: 'experience', title: '명 성', type: 'int' },
|
||||
{ key: 'dedication', title: '계 급', type: 'int' },
|
||||
{ key: 'firenum', title: '계 략 성 공', type: 'int' },
|
||||
{ key: 'warnum', title: '전 투 횟 수', type: 'int' },
|
||||
{ key: 'killnum', title: '승 리', type: 'int' },
|
||||
{ key: 'winrate', title: '승 률', type: 'percent' },
|
||||
{ key: 'occupied', title: '점 령', type: 'int' },
|
||||
{ key: 'killcrew', title: '사 살', type: 'int' },
|
||||
{ key: 'killrate', title: '살 상 률', type: 'percent' },
|
||||
{ key: 'killcrew_person', title: '대 인 사 살', type: 'int' },
|
||||
{ key: 'killrate_person', title: '대 인 살 상 률', type: 'percent' },
|
||||
{ key: 'dex1', title: '보 병 숙 련 도', type: 'int' },
|
||||
{ key: 'dex2', title: '궁 병 숙 련 도', type: 'int' },
|
||||
{ key: 'dex3', title: '기 병 숙 련 도', type: 'int' },
|
||||
{ key: 'dex4', title: '귀 병 숙 련 도', type: 'int' },
|
||||
{ key: 'dex5', title: '차 병 숙 련 도', type: 'int' },
|
||||
{ key: 'ttrate', title: '전 력 전 승 률', type: 'percent' },
|
||||
{ key: 'tlrate', title: '통 솔 전 승 률', type: 'percent' },
|
||||
{ key: 'tsrate', title: '일 기 토 승 률', type: 'percent' },
|
||||
{ key: 'tirate', title: '설 전 승 률', type: 'percent' },
|
||||
{ key: 'betgold', title: '베 팅 투 자 액', type: 'int' },
|
||||
{ key: 'betwin', title: '베 팅 당 첨', type: 'int' },
|
||||
{ key: 'betwingold', title: '베 팅 수 익 금', type: 'int' },
|
||||
{ key: 'betrate', title: '베 팅 수 익 률', type: 'percent' },
|
||||
];
|
||||
const allowedTypes = new Set(HALL_OF_FAME_TYPES);
|
||||
|
||||
const sections = await Promise.all(
|
||||
types.map(async (type) => {
|
||||
if (!allowedTypes.has(type.key)) {
|
||||
return { title: type.title, valueType: type.type, entries: [] };
|
||||
}
|
||||
const rows = await ctx.db.hallOfFame.findMany({
|
||||
where: { ...baseWhere, type: type.key },
|
||||
orderBy: { value: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
const entries = rows.map((row) => {
|
||||
const aux = asRecord(row.aux);
|
||||
return {
|
||||
generalId: row.generalNo,
|
||||
name: String(aux.name ?? ''),
|
||||
nationName: String(aux.nationName ?? ''),
|
||||
bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR),
|
||||
fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR),
|
||||
picture: typeof aux.picture === 'string' ? aux.picture : null,
|
||||
imageServer: readMetaNumber(aux.imgsvr),
|
||||
value: row.value,
|
||||
printValue: type.type === 'percent' ? percentText(row.value) : Math.floor(row.value).toLocaleString('ko-KR'),
|
||||
serverName: String(aux.serverName ?? ''),
|
||||
serverIdx: readMetaNumber(aux.serverIdx),
|
||||
scenarioName: String(aux.scenarioName ?? ''),
|
||||
startTime: typeof aux.startTime === 'string' ? aux.startTime : null,
|
||||
unitedTime: typeof aux.unitedTime === 'string' ? aux.unitedTime : null,
|
||||
};
|
||||
});
|
||||
return { title: type.title, valueType: type.type, entries };
|
||||
})
|
||||
);
|
||||
|
||||
return { sections };
|
||||
}),
|
||||
});
|
||||
@@ -35,6 +35,7 @@ export interface ScenarioInstallOptions {
|
||||
autorunUser?: ScenarioAutorunOptions | null;
|
||||
preopenAt?: Date | null;
|
||||
season?: number;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export interface ScenarioSeedOptions {
|
||||
@@ -258,6 +259,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
if (typeof install?.season === 'number' && Number.isFinite(install.season)) {
|
||||
worldMeta.season = Math.floor(install.season);
|
||||
}
|
||||
if (typeof install?.serverId === 'string' && install.serverId.trim()) {
|
||||
worldMeta.serverId = install.serverId.trim();
|
||||
}
|
||||
|
||||
const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV];
|
||||
if (typeof integrationSeed === 'string' && integrationSeed.trim().length > 0) {
|
||||
@@ -304,6 +308,43 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
},
|
||||
});
|
||||
|
||||
if (typeof worldMeta.serverId === 'string' && worldMeta.serverId) {
|
||||
await prisma.gameHistory.upsert({
|
||||
where: { serverId: worldMeta.serverId },
|
||||
create: {
|
||||
serverId: worldMeta.serverId,
|
||||
date: now,
|
||||
winnerNation: null,
|
||||
map: scenario.config.environment.mapName ?? null,
|
||||
season:
|
||||
typeof worldMeta.season === 'number' && Number.isFinite(worldMeta.season)
|
||||
? Math.floor(worldMeta.season)
|
||||
: 1,
|
||||
scenario: options.scenarioId,
|
||||
scenarioName: String(seed.scenarioMeta?.title ?? ''),
|
||||
env: asJson({
|
||||
config: scenarioConfig,
|
||||
meta: worldMeta,
|
||||
}),
|
||||
},
|
||||
update: {
|
||||
date: now,
|
||||
winnerNation: null,
|
||||
map: scenario.config.environment.mapName ?? null,
|
||||
season:
|
||||
typeof worldMeta.season === 'number' && Number.isFinite(worldMeta.season)
|
||||
? Math.floor(worldMeta.season)
|
||||
: 1,
|
||||
scenario: options.scenarioId,
|
||||
scenarioName: String(seed.scenarioMeta?.title ?? ''),
|
||||
env: asJson({
|
||||
config: scenarioConfig,
|
||||
meta: worldMeta,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (seed.nations.length > 0) {
|
||||
await prisma.nation.createMany({
|
||||
data: seed.nations.map((nation) => ({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type TurnEngineWorldStateUpdateInput,
|
||||
} from '@sammo-ts/infra';
|
||||
import { finalizeLogEntry, type LogEntryDraft } from '@sammo-ts/logic';
|
||||
import { asRecord, type RankDataType } from '@sammo-ts/common';
|
||||
|
||||
import type { TurnDaemonHooks } from '../lifecycle/types.js';
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
@@ -33,6 +34,79 @@ const readMetaNumber = (meta: Record<string, unknown>, key: string): number | nu
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
};
|
||||
|
||||
const readRankMetaNumber = (meta: Record<string, unknown>, key: string): number => {
|
||||
const value = meta[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const buildRankRows = (
|
||||
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
||||
): Array<{ generalId: number; nationId: number; type: string; value: number }> => {
|
||||
const meta = asRecord(general.meta);
|
||||
const readMeta = (key: string) => readRankMetaNumber(meta, key);
|
||||
const readRank = (key: string) => readRankMetaNumber(meta, `rank_${key}`);
|
||||
|
||||
const entries: Array<[RankDataType, number]> = [
|
||||
['experience', Math.floor(general.experience)],
|
||||
['dedication', Math.floor(general.dedication)],
|
||||
['firenum', readMeta('firenum')],
|
||||
['warnum', readRank('warnum')],
|
||||
['killnum', readRank('killnum')],
|
||||
['deathnum', readRank('deathnum')],
|
||||
['occupied', readRank('occupied')],
|
||||
['killcrew', readRank('killcrew')],
|
||||
['deathcrew', readRank('deathcrew')],
|
||||
['killcrew_person', readRank('killcrew_person')],
|
||||
['deathcrew_person', readRank('deathcrew_person')],
|
||||
['dex1', readMeta('dex1')],
|
||||
['dex2', readMeta('dex2')],
|
||||
['dex3', readMeta('dex3')],
|
||||
['dex4', readMeta('dex4')],
|
||||
['dex5', readMeta('dex5')],
|
||||
['ttw', readMeta('ttw')],
|
||||
['ttd', readMeta('ttd')],
|
||||
['ttl', readMeta('ttl')],
|
||||
['ttg', readMeta('ttg')],
|
||||
['ttp', readMeta('ttp')],
|
||||
['tlw', readMeta('tlw')],
|
||||
['tld', readMeta('tld')],
|
||||
['tll', readMeta('tll')],
|
||||
['tlg', readMeta('tlg')],
|
||||
['tlp', readMeta('tlp')],
|
||||
['tsw', readMeta('tsw')],
|
||||
['tsd', readMeta('tsd')],
|
||||
['tsl', readMeta('tsl')],
|
||||
['tsg', readMeta('tsg')],
|
||||
['tsp', readMeta('tsp')],
|
||||
['tiw', readMeta('tiw')],
|
||||
['tid', readMeta('tid')],
|
||||
['til', readMeta('til')],
|
||||
['tig', readMeta('tig')],
|
||||
['tip', readMeta('tip')],
|
||||
['betgold', readMeta('betgold')],
|
||||
['betwin', readMeta('betwin')],
|
||||
['betwingold', readMeta('betwingold')],
|
||||
['inherit_earned', readMeta('inherit_earned')],
|
||||
['inherit_spent', readMeta('inherit_spent')],
|
||||
];
|
||||
|
||||
return entries.map(([type, value]) => ({
|
||||
generalId: general.id,
|
||||
nationId: general.nationId,
|
||||
type,
|
||||
value,
|
||||
}));
|
||||
};
|
||||
|
||||
const buildGeneralUpdate = (
|
||||
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
||||
): TurnEngineGeneralUpdateInput => ({
|
||||
@@ -308,6 +382,9 @@ export const createDatabaseTurnHooks = async (
|
||||
await prisma.general.deleteMany({
|
||||
where: { id: { in: deletedGenerals } },
|
||||
});
|
||||
await prisma.rankData.deleteMany({
|
||||
where: { generalId: { in: deletedGenerals } },
|
||||
});
|
||||
}
|
||||
|
||||
if (deletedNations.length > 0) {
|
||||
@@ -377,6 +454,28 @@ export const createDatabaseTurnHooks = async (
|
||||
),
|
||||
]);
|
||||
|
||||
const rankTargets = [...createdGenerals, ...generals];
|
||||
if (rankTargets.length > 0) {
|
||||
const rankRows = rankTargets.flatMap(buildRankRows);
|
||||
await Promise.all(
|
||||
rankRows.map((row) =>
|
||||
prisma.rankData.upsert({
|
||||
where: {
|
||||
generalId_type: {
|
||||
generalId: row.generalId,
|
||||
type: row.type,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
nationId: row.nationId,
|
||||
value: row.value,
|
||||
},
|
||||
create: row,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (logs.length > 0) {
|
||||
const logContext = {
|
||||
year: state.currentYear,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createGamePostgresConnector } from '@sammo-ts/infra';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common';
|
||||
import type { LogEntryDraft } from '@sammo-ts/logic';
|
||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
|
||||
|
||||
@@ -16,6 +16,27 @@ const readMetaNumber = (meta: Record<string, unknown>, key: string): number => {
|
||||
return value;
|
||||
};
|
||||
|
||||
const readMetaNumberOrNull = (meta: Record<string, unknown>, key: string): number | null => {
|
||||
const value = meta[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const computeHallRate = (numerator: number, denominator: number): number => {
|
||||
if (denominator <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return numerator / denominator;
|
||||
};
|
||||
|
||||
const computeDexPoint = (meta: Record<string, unknown>): number => {
|
||||
let total = 0;
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
@@ -162,6 +183,203 @@ export const createUnificationHandler = (options: {
|
||||
}
|
||||
};
|
||||
|
||||
const settleHallOfFame = 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 season = readMetaNumberOrNull(meta, 'season') ?? 1;
|
||||
const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0;
|
||||
const scenarioName =
|
||||
typeof asRecord(meta.scenarioMeta).title === 'string'
|
||||
? String(asRecord(meta.scenarioMeta).title)
|
||||
: '';
|
||||
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
|
||||
const unitedTime = new Date().toISOString();
|
||||
|
||||
const [serverCount, nationRows, generalRows, rankRows] = await Promise.all([
|
||||
prisma.gameHistory.count(),
|
||||
prisma.nation.findMany({ select: { id: true, name: true, color: true } }),
|
||||
prisma.general.findMany({
|
||||
where: { npcState: { lt: 2 } },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
nationId: true,
|
||||
name: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
},
|
||||
}),
|
||||
prisma.rankData.findMany({
|
||||
where: { generalId: { gt: 0 } },
|
||||
select: { generalId: true, type: true, value: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const nationMap = new Map<number, { name: string; color: string }>();
|
||||
for (const nation of nationRows) {
|
||||
nationMap.set(nation.id, { name: nation.name, color: nation.color });
|
||||
}
|
||||
|
||||
const rankMap = new Map<number, Record<string, number>>();
|
||||
for (const row of rankRows) {
|
||||
const entry = rankMap.get(row.generalId) ?? {};
|
||||
entry[row.type] = row.value;
|
||||
rankMap.set(row.generalId, entry);
|
||||
}
|
||||
|
||||
const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => {
|
||||
if (type === 'experience' || type === 'dedication' || type.startsWith('dex')) {
|
||||
return [type, 'natural'];
|
||||
}
|
||||
if (type.endsWith('rate')) {
|
||||
return [type, 'calc'];
|
||||
}
|
||||
return [type, 'rank'];
|
||||
});
|
||||
|
||||
for (const general of generalRows) {
|
||||
const ranks = rankMap.get(general.id) ?? {};
|
||||
const warnum = ranks.warnum ?? 0;
|
||||
const killnum = ranks.killnum ?? 0;
|
||||
const killcrew = ranks.killcrew ?? 0;
|
||||
const deathcrew = ranks.deathcrew ?? 0;
|
||||
const killcrewPerson = ranks.killcrew_person ?? 0;
|
||||
const deathcrewPerson = ranks.deathcrew_person ?? 0;
|
||||
const ttw = ranks.ttw ?? 0;
|
||||
const ttd = ranks.ttd ?? 0;
|
||||
const ttl = ranks.ttl ?? 0;
|
||||
const tlw = ranks.tlw ?? 0;
|
||||
const tld = ranks.tld ?? 0;
|
||||
const tll = ranks.tll ?? 0;
|
||||
const tsw = ranks.tsw ?? 0;
|
||||
const tsd = ranks.tsd ?? 0;
|
||||
const tsl = ranks.tsl ?? 0;
|
||||
const tiw = ranks.tiw ?? 0;
|
||||
const tid = ranks.tid ?? 0;
|
||||
const til = ranks.til ?? 0;
|
||||
const betGold = ranks.betgold ?? 0;
|
||||
const betWinGold = ranks.betwingold ?? 0;
|
||||
|
||||
const ttTotal = ttw + ttd + ttl;
|
||||
const tlTotal = tlw + tld + tll;
|
||||
const tsTotal = tsw + tsd + tsl;
|
||||
const tiTotal = tiw + tid + til;
|
||||
|
||||
const calcValues: Record<string, number> = {
|
||||
winrate: computeHallRate(killnum, warnum),
|
||||
killrate: computeHallRate(killcrew, Math.max(1, deathcrew)),
|
||||
killrate_person: computeHallRate(killcrewPerson, Math.max(1, deathcrewPerson)),
|
||||
ttrate: computeHallRate(ttw, Math.max(1, ttTotal)),
|
||||
tlrate: computeHallRate(tlw, Math.max(1, tlTotal)),
|
||||
tsrate: computeHallRate(tsw, Math.max(1, tsTotal)),
|
||||
tirate: computeHallRate(tiw, Math.max(1, tiTotal)),
|
||||
betrate: computeHallRate(betWinGold, Math.max(1, betGold)),
|
||||
};
|
||||
|
||||
const nation = nationMap.get(general.nationId) ?? { name: '재야', color: '#000000' };
|
||||
const aux = {
|
||||
name: general.name,
|
||||
nationName: nation.name,
|
||||
bgColor: nation.color,
|
||||
fgColor: nation.color,
|
||||
picture: general.picture,
|
||||
imgsvr: general.imageServer,
|
||||
startTime,
|
||||
unitedTime,
|
||||
ownerName: general.userId ?? null,
|
||||
serverID: serverId,
|
||||
serverIdx: serverCount,
|
||||
serverName: options.profileName,
|
||||
scenarioName,
|
||||
};
|
||||
|
||||
for (const [typeName, valueType] of hallTypes) {
|
||||
let value = 0;
|
||||
if (valueType === 'natural') {
|
||||
value = typeName === 'experience' ? general.experience : typeName === 'dedication' ? general.dedication : ranks[typeName] ?? 0;
|
||||
} else if (valueType === 'rank') {
|
||||
value = ranks[typeName] ?? 0;
|
||||
} else {
|
||||
value = calcValues[typeName] ?? 0;
|
||||
}
|
||||
|
||||
if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) {
|
||||
continue;
|
||||
}
|
||||
if (typeName === 'ttrate' && ttTotal < 50) {
|
||||
continue;
|
||||
}
|
||||
if (typeName === 'tlrate' && tlTotal < 50) {
|
||||
continue;
|
||||
}
|
||||
if (typeName === 'tsrate' && tsTotal < 50) {
|
||||
continue;
|
||||
}
|
||||
if (typeName === 'tirate' && tiTotal < 50) {
|
||||
continue;
|
||||
}
|
||||
if (typeName === 'betrate' && betGold < 1000) {
|
||||
continue;
|
||||
}
|
||||
if (value <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.hallOfFame.findUnique({
|
||||
where: {
|
||||
serverId_type_generalNo: {
|
||||
serverId,
|
||||
type: typeName,
|
||||
generalNo: general.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
await prisma.hallOfFame.create({
|
||||
data: {
|
||||
serverId,
|
||||
season,
|
||||
scenario,
|
||||
generalNo: general.id,
|
||||
type: typeName,
|
||||
value,
|
||||
owner: general.userId ?? null,
|
||||
aux,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (value > existing.value) {
|
||||
await prisma.hallOfFame.update({
|
||||
where: { id: existing.id },
|
||||
data: { value, aux },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.gameHistory.update({
|
||||
where: { serverId },
|
||||
data: {
|
||||
winnerNation: winnerNationId,
|
||||
date: new Date(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handler: TurnCalendarHandler = {
|
||||
onMonthChanged: (context) => {
|
||||
const world = options.getWorld();
|
||||
@@ -188,6 +406,7 @@ export const createUnificationHandler = (options: {
|
||||
world.updateWorldMeta({ isUnited: 2 });
|
||||
world.pushLog(buildUnificationLog(winner.name));
|
||||
void settleInheritance(winner.id, context.currentYear, context.currentMonth);
|
||||
void settleHallOfFame(winner.id);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import BoardView from '../views/BoardView.vue';
|
||||
import NationAffairsView from '../views/NationAffairsView.vue';
|
||||
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 { useSessionStore } from '../stores/session';
|
||||
|
||||
const routes = [
|
||||
@@ -163,6 +165,19 @@ const routes = [
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/best-general',
|
||||
name: 'best-general',
|
||||
component: BestGeneralView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/hall-of-fame',
|
||||
name: 'hall-of-fame',
|
||||
component: HallOfFameView,
|
||||
},
|
||||
{
|
||||
path: '/my-page',
|
||||
name: 'my-page',
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type RankEntry = {
|
||||
id: number;
|
||||
name: string;
|
||||
ownerName: string | null;
|
||||
nationName: string;
|
||||
bgColor: string;
|
||||
fgColor: string;
|
||||
picture: string | null;
|
||||
imageServer: number;
|
||||
value: number;
|
||||
printValue: string;
|
||||
};
|
||||
|
||||
type RankSection = {
|
||||
title: string;
|
||||
valueType: 'int' | 'percent';
|
||||
entries: RankEntry[];
|
||||
};
|
||||
|
||||
type UniqueOwner = {
|
||||
id: number;
|
||||
name: string;
|
||||
nationName: string;
|
||||
bgColor: string;
|
||||
fgColor: string;
|
||||
};
|
||||
|
||||
type UniqueItemSection = {
|
||||
title: string;
|
||||
slot: string;
|
||||
owners: UniqueOwner[];
|
||||
};
|
||||
|
||||
type BestGeneralPayload = {
|
||||
isUnited: boolean;
|
||||
sections: RankSection[];
|
||||
uniqueItems: UniqueItemSection[];
|
||||
};
|
||||
|
||||
const viewMode = ref<'user' | 'npc'>('user');
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const data = ref<BestGeneralPayload | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
data.value = await trpc.ranking.getBestGeneral.query({ view: viewMode.value });
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '명장일람을 불러오지 못했습니다.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const emptyLabel = computed(() => (loading.value ? '불러오는 중...' : '표시할 데이터가 없습니다.'));
|
||||
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
});
|
||||
|
||||
watch(viewMode, () => {
|
||||
void refresh();
|
||||
});
|
||||
</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" :class="{ active: viewMode === 'user' }" @click="viewMode = 'user'">
|
||||
유저 보기
|
||||
</button>
|
||||
<button class="ghost" :class="{ active: viewMode === 'npc' }" @click="viewMode = 'npc'">
|
||||
NPC 보기
|
||||
</button>
|
||||
<button class="ghost" @click="refresh">새로고침</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
|
||||
<div v-else-if="!data" class="placeholder">{{ emptyLabel }}</div>
|
||||
|
||||
<section v-if="data" class="grid gap-4">
|
||||
<div v-for="section in data.sections" :key="section.title" class="bg-zinc-900 border border-zinc-800 rounded p-4">
|
||||
<h2 class="text-base font-semibold mb-3">{{ section.title }}</h2>
|
||||
<div v-if="section.entries.length === 0" class="text-xs text-zinc-500">{{ emptyLabel }}</div>
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="entry in section.entries"
|
||||
:key="entry.id"
|
||||
class="flex items-center justify-between bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: entry.bgColor }" />
|
||||
<span class="font-semibold">{{ entry.name }}</span>
|
||||
<span class="text-xs text-zinc-400">{{ entry.nationName }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-200">{{ entry.printValue }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="data" class="mt-6 bg-zinc-900 border border-zinc-800 rounded p-4">
|
||||
<h2 class="text-base font-semibold mb-3">유니크 아이템 소유자</h2>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div v-for="item in data.uniqueItems" :key="item.title" class="bg-zinc-950 border border-zinc-800 rounded p-3">
|
||||
<h3 class="text-sm font-semibold mb-2">{{ item.title }}</h3>
|
||||
<ul class="space-y-1 text-xs">
|
||||
<li v-for="owner in item.owners" :key="owner.id" class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: owner.bgColor }" />
|
||||
<span>{{ owner.name }}</span>
|
||||
<span class="text-zinc-500">{{ owner.nationName }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type HallOption = {
|
||||
season: number;
|
||||
scenarios: Array<{ id: number; name: string; count: number }>;
|
||||
};
|
||||
|
||||
type HallEntry = {
|
||||
generalId: number;
|
||||
name: string;
|
||||
nationName: string;
|
||||
bgColor: string;
|
||||
fgColor: string;
|
||||
value: number;
|
||||
printValue: string;
|
||||
serverName: string;
|
||||
serverIdx: number;
|
||||
scenarioName: string;
|
||||
startTime: string | null;
|
||||
unitedTime: string | null;
|
||||
};
|
||||
|
||||
type HallSection = {
|
||||
title: string;
|
||||
valueType: 'int' | 'percent';
|
||||
entries: HallEntry[];
|
||||
};
|
||||
|
||||
type HallPayload = {
|
||||
sections: HallSection[];
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const options = ref<HallOption[]>([]);
|
||||
const selectedSeason = ref<number | null>(null);
|
||||
const selectedScenario = ref<number | null>(null);
|
||||
const data = ref<HallPayload | null>(null);
|
||||
|
||||
const selectedSeasonOptions = computed(() => {
|
||||
if (selectedSeason.value === null) {
|
||||
return [];
|
||||
}
|
||||
return options.value.find((season) => season.season === selectedSeason.value)?.scenarios ?? [];
|
||||
});
|
||||
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
options.value = await trpc.ranking.getHallOfFameOptions.query();
|
||||
if (options.value.length > 0 && selectedSeason.value === null) {
|
||||
selectedSeason.value = options.value[0]!.season;
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '명예의 전당 옵션을 불러오지 못했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const loadHall = async () => {
|
||||
if (selectedSeason.value === null) {
|
||||
data.value = null;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const result = await trpc.ranking.getHallOfFame.query({
|
||||
season: selectedSeason.value,
|
||||
scenario: selectedScenario.value ?? undefined,
|
||||
});
|
||||
data.value = result as HallPayload;
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '명예의 전당 데이터를 불러오지 못했습니다.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(selectedSeason, () => {
|
||||
selectedScenario.value = null;
|
||||
void loadHall();
|
||||
});
|
||||
|
||||
watch(selectedScenario, () => {
|
||||
void loadHall();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadOptions();
|
||||
await loadHall();
|
||||
});
|
||||
</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="loadOptions">목록 새로고침</button>
|
||||
<button class="ghost" @click="loadHall">데이터 새로고침</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded p-4 mb-4">
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<label class="flex flex-col gap-2 text-sm">
|
||||
<span class="text-xs text-zinc-400">시즌 선택</span>
|
||||
<select v-model.number="selectedSeason" class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2">
|
||||
<option v-for="season in options" :key="season.season" :value="season.season">
|
||||
시즌 {{ season.season }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-2 text-sm">
|
||||
<span class="text-xs text-zinc-400">시나리오 선택</span>
|
||||
<select v-model.number="selectedScenario" class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2">
|
||||
<option :value="null">전체</option>
|
||||
<option v-for="scenario in selectedSeasonOptions" :key="scenario.id" :value="scenario.id">
|
||||
{{ scenario.name }} ({{ scenario.count }}회)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div v-for="section in data.sections" :key="section.title" class="bg-zinc-900 border border-zinc-800 rounded p-4">
|
||||
<h2 class="text-base font-semibold mb-3">{{ section.title }}</h2>
|
||||
<div v-if="section.entries.length === 0" class="text-xs text-zinc-500">표시할 데이터가 없습니다.</div>
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="entry in section.entries"
|
||||
:key="entry.generalId"
|
||||
class="flex items-center justify-between bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: entry.bgColor }" />
|
||||
<span class="font-semibold">{{ entry.name }}</span>
|
||||
<span class="text-xs text-zinc-400">{{ entry.nationName }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-200">{{ entry.printValue }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -98,6 +98,8 @@ watch(
|
||||
<RouterLink class="ghost" to="/diplomacy">외교부</RouterLink>
|
||||
<RouterLink class="ghost" to="/chief-center">사령부</RouterLink>
|
||||
<RouterLink class="ghost" to="/battle-center">감찰부</RouterLink>
|
||||
<RouterLink class="ghost" to="/best-general">명장일람</RouterLink>
|
||||
<RouterLink class="ghost" to="/hall-of-fame">명예의 전당</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>
|
||||
|
||||
@@ -321,6 +321,14 @@ const applySanctionsPatch = (current: UserSanctions, patch: SanctionsPatch): Use
|
||||
|
||||
const buildAdminPassword = (): string => randomBytes(6).toString('hex');
|
||||
|
||||
const buildServerId = (profileName: string, now: Date): string => {
|
||||
const year = String(now.getFullYear()).slice(-2);
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const suffix = randomBytes(2).toString('hex');
|
||||
return `${profileName}_${year}${month}${day}_${suffix}`;
|
||||
};
|
||||
|
||||
// 프로필 메타를 안전하게 읽고, 패치를 병합한다.
|
||||
const readMetaObject = (value: unknown): Record<string, unknown> => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
@@ -881,11 +889,13 @@ export const adminRouter = router({
|
||||
}
|
||||
const season = nextSeasonIdx ?? baseSeason ?? 1;
|
||||
|
||||
const seedNow = new Date();
|
||||
const serverId = buildServerId(updatedProfile.profileName, seedNow);
|
||||
await seedProfileDatabase({
|
||||
databaseUrl,
|
||||
scenarioId: input.install.scenarioId,
|
||||
tickSeconds: input.install.turnTermMinutes * 60,
|
||||
now: new Date(),
|
||||
now: seedNow,
|
||||
installOptions: {
|
||||
turnTermMinutes: input.install.turnTermMinutes,
|
||||
sync: input.install.sync,
|
||||
@@ -897,6 +907,7 @@ export const adminRouter = router({
|
||||
tournamentTrig: input.install.tournamentTrig,
|
||||
joinMode: input.install.joinMode,
|
||||
season,
|
||||
serverId,
|
||||
autorunUser: input.install.autorunUser
|
||||
? {
|
||||
limitMinutes: input.install.autorunUser.limitMinutes,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { type ScenarioInstallOptions } from '@sammo-ts/game-engine';
|
||||
import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra';
|
||||
@@ -113,6 +114,14 @@ interface GatewayAdminActionResult {
|
||||
|
||||
const normalizeMeta = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
|
||||
|
||||
const buildServerId = (profileName: string, now: Date): string => {
|
||||
const year = String(now.getFullYear()).slice(-2);
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const suffix = randomBytes(2).toString('hex');
|
||||
return `${profileName}_${year}${month}${day}_${suffix}`;
|
||||
};
|
||||
|
||||
const readMetaNumber = (meta: Record<string, unknown>, key: string): number | null => {
|
||||
const raw = meta[key];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
@@ -663,6 +672,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
}
|
||||
const workspace = await this.workspaceManager.prepare(commitSha);
|
||||
const resourceRoot = path.join(workspace.root, 'resources');
|
||||
const serverId = buildServerId(profile.profileName, seedTime);
|
||||
await seedProfileDatabase({
|
||||
databaseUrl: seedInfo.databaseUrl,
|
||||
scenarioId: seedInfo.scenarioId,
|
||||
@@ -671,6 +681,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
installOptions: {
|
||||
...(installOptions ?? {}),
|
||||
season,
|
||||
serverId,
|
||||
},
|
||||
scenarioOptions: { scenarioRoot: path.join(resourceRoot, 'scenario') },
|
||||
mapOptions: { mapRoot: path.join(resourceRoot, 'map') },
|
||||
|
||||
@@ -15,3 +15,4 @@ export * from './tournament/autoStart.js';
|
||||
export * from './turnDaemon/types.js';
|
||||
export * from './realtime/keys.js';
|
||||
export * from './realtime/types.js';
|
||||
export * from './ranking/types.js';
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export const RANK_DATA_TYPES = [
|
||||
'experience',
|
||||
'dedication',
|
||||
'firenum',
|
||||
'warnum',
|
||||
'killnum',
|
||||
'deathnum',
|
||||
'occupied',
|
||||
'killcrew',
|
||||
'deathcrew',
|
||||
'killcrew_person',
|
||||
'deathcrew_person',
|
||||
'dex1',
|
||||
'dex2',
|
||||
'dex3',
|
||||
'dex4',
|
||||
'dex5',
|
||||
'ttw',
|
||||
'ttd',
|
||||
'ttl',
|
||||
'ttg',
|
||||
'ttp',
|
||||
'tlw',
|
||||
'tld',
|
||||
'tll',
|
||||
'tlg',
|
||||
'tlp',
|
||||
'tsw',
|
||||
'tsd',
|
||||
'tsl',
|
||||
'tsg',
|
||||
'tsp',
|
||||
'tiw',
|
||||
'tid',
|
||||
'til',
|
||||
'tig',
|
||||
'tip',
|
||||
'betwin',
|
||||
'betgold',
|
||||
'betwingold',
|
||||
'inherit_earned',
|
||||
'inherit_spent',
|
||||
] as const;
|
||||
|
||||
export type RankDataType = (typeof RANK_DATA_TYPES)[number];
|
||||
|
||||
export const HALL_OF_FAME_TYPES = [
|
||||
'experience',
|
||||
'dedication',
|
||||
'firenum',
|
||||
'warnum',
|
||||
'killnum',
|
||||
'winrate',
|
||||
'occupied',
|
||||
'killcrew',
|
||||
'killrate',
|
||||
'killcrew_person',
|
||||
'killrate_person',
|
||||
'dex1',
|
||||
'dex2',
|
||||
'dex3',
|
||||
'dex4',
|
||||
'dex5',
|
||||
'ttrate',
|
||||
'tlrate',
|
||||
'tsrate',
|
||||
'tirate',
|
||||
'betgold',
|
||||
'betwin',
|
||||
'betwingold',
|
||||
'betrate',
|
||||
] as const;
|
||||
|
||||
export type HallOfFameType = (typeof HALL_OF_FAME_TYPES)[number];
|
||||
@@ -146,6 +146,53 @@ model General {
|
||||
@@map("general")
|
||||
}
|
||||
|
||||
model RankData {
|
||||
id Int @id @default(autoincrement())
|
||||
nationId Int @default(0) @map("nation_id")
|
||||
generalId Int @map("general_id")
|
||||
type String @db.VarChar(20)
|
||||
value Int @default(0)
|
||||
|
||||
@@unique([generalId, type])
|
||||
@@index([type, value], name: "by_type")
|
||||
@@index([nationId, type, value], name: "by_nation")
|
||||
@@map("rank_data")
|
||||
}
|
||||
|
||||
model HallOfFame {
|
||||
id Int @id @default(autoincrement())
|
||||
serverId String @map("server_id")
|
||||
season Int
|
||||
scenario Int
|
||||
generalNo Int @map("general_no")
|
||||
type String @db.VarChar(20)
|
||||
value Float
|
||||
owner String? @map("owner")
|
||||
aux Json @default(dbgenerated("'{}'::jsonb"))
|
||||
|
||||
@@unique([serverId, type, generalNo])
|
||||
@@unique([owner, serverId, type], name: "hall_owner")
|
||||
@@index([serverId, type, value], name: "server_show")
|
||||
@@index([season, scenario, type, value], name: "scenario")
|
||||
@@map("hall")
|
||||
}
|
||||
|
||||
model GameHistory {
|
||||
id Int @id @default(autoincrement())
|
||||
serverId String @map("server_id")
|
||||
date DateTime
|
||||
winnerNation Int? @map("winner_nation")
|
||||
map String? @map("map")
|
||||
season Int
|
||||
scenario Int
|
||||
scenarioName String @map("scenario_name")
|
||||
env Json @default(dbgenerated("'{}'::jsonb"))
|
||||
|
||||
@@unique([serverId])
|
||||
@@index([date])
|
||||
@@map("ng_games")
|
||||
}
|
||||
|
||||
model Troop {
|
||||
troopLeaderId Int @id @map("troop_leader")
|
||||
nationId Int @map("nation")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE "rank_data" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"nation_id" INTEGER NOT NULL DEFAULT 0,
|
||||
"general_id" INTEGER NOT NULL,
|
||||
"type" VARCHAR(20) NOT NULL,
|
||||
"value" INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "rank_data_by_general" ON "rank_data"("general_id", "type");
|
||||
CREATE INDEX "rank_data_by_type" ON "rank_data"("type", "value");
|
||||
CREATE INDEX "rank_data_by_nation" ON "rank_data"("nation_id", "type", "value");
|
||||
|
||||
CREATE TABLE "hall" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"server_id" TEXT NOT NULL,
|
||||
"season" INTEGER NOT NULL,
|
||||
"scenario" INTEGER NOT NULL,
|
||||
"general_no" INTEGER NOT NULL,
|
||||
"type" VARCHAR(20) NOT NULL,
|
||||
"value" DOUBLE PRECISION NOT NULL,
|
||||
"owner" TEXT,
|
||||
"aux" JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "hall_server_general" ON "hall"("server_id", "type", "general_no");
|
||||
CREATE UNIQUE INDEX "hall_owner" ON "hall"("owner", "server_id", "type");
|
||||
CREATE INDEX "hall_server_show" ON "hall"("server_id", "type", "value");
|
||||
CREATE INDEX "hall_scenario" ON "hall"("season", "scenario", "type", "value");
|
||||
|
||||
CREATE TABLE "ng_games" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"server_id" TEXT NOT NULL,
|
||||
"date" TIMESTAMP(3) NOT NULL,
|
||||
"winner_nation" INTEGER,
|
||||
"map" TEXT,
|
||||
"season" INTEGER NOT NULL,
|
||||
"scenario" INTEGER NOT NULL,
|
||||
"scenario_name" TEXT NOT NULL,
|
||||
"env" JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "ng_games_server_id" ON "ng_games"("server_id");
|
||||
CREATE INDEX "ng_games_date" ON "ng_games"("date");
|
||||
@@ -11,6 +11,9 @@ export interface DatabaseClient {
|
||||
diplomacy: GamePrisma.DiplomacyDelegate;
|
||||
diplomacyLetter: GamePrisma.DiplomacyLetterDelegate;
|
||||
yearbookHistory: GamePrisma.YearbookHistoryDelegate;
|
||||
rankData: GamePrisma.RankDataDelegate;
|
||||
hallOfFame: GamePrisma.HallOfFameDelegate;
|
||||
gameHistory: GamePrisma.GameHistoryDelegate;
|
||||
generalTurn: GamePrisma.GeneralTurnDelegate;
|
||||
nationTurn: GamePrisma.NationTurnDelegate;
|
||||
troop: GamePrisma.TroopDelegate;
|
||||
|
||||
Reference in New Issue
Block a user