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:
2026-01-29 18:18:35 +00:00
parent 19c359d0b2
commit 9fff671c2f
16 changed files with 1217 additions and 2 deletions
+2
View File
@@ -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;
+362
View File
@@ -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) => ({
+99
View File
@@ -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,
+220 -1
View File
@@ -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);
},
};
+15
View File
@@ -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>
+2
View File
@@ -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>
+12 -1
View File
@@ -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') },
+1
View File
@@ -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';
+74
View File
@@ -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];
+47
View File
@@ -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");
+3
View File
@@ -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;