Merge branch 'main' into feature/nation-seizure-npc-message-parity

This commit is contained in:
2026-07-26 06:06:12 +00:00
39 changed files with 706 additions and 329 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}):
continue;
}
let job: BattleSimJob | null = null;
let job: BattleSimJob;
try {
job = JSON.parse(raw) as BattleSimJob;
} catch {
+9 -7
View File
@@ -2,14 +2,12 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { GamePrisma } from '@sammo-ts/infra';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
const zLetterState = z.enum(['PROPOSED', 'ACTIVATED', 'CANCELLED', 'REPLACED']);
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
const nation = await ctx.db.nation.findUnique({
where: { id: nationId },
@@ -22,7 +20,7 @@ const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], n
return resolveNationPermission(general, nation.meta, true);
};
const mapLetterState = (state: string): z.infer<typeof zLetterState> => {
const mapLetterState = (state: string): 'PROPOSED' | 'ACTIVATED' | 'CANCELLED' | 'REPLACED' => {
if (state === 'ACTIVATED') return 'ACTIVATED';
if (state === 'CANCELLED') return 'CANCELLED';
if (state === 'REPLACED') return 'REPLACED';
@@ -153,7 +151,10 @@ export const diplomacyRouter = router({
select: { id: true },
});
if (newer) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '해당 문서에 대한 새로운 문서가 이미 있습니다.' });
throw new TRPCError({
code: 'BAD_REQUEST',
message: '해당 문서에 대한 새로운 문서가 이미 있습니다.',
});
}
if (prevLetter.state === 'PROPOSED') {
@@ -169,7 +170,8 @@ export const diplomacyRouter = router({
});
}
destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
destNationId =
prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
}
const nations = await ctx.db.nation.findMany({
@@ -372,4 +374,4 @@ export const diplomacyRouter = router({
});
return { state: 'ACTIVATED' };
}),
});
});
+55 -15
View File
@@ -8,8 +8,29 @@ import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { authedProcedure, procedure, router } from '../../trpc.js';
const DEFAULT_BG_COLOR = '#2b2b2b';
const DEFAULT_BG_COLOR = '#330000';
const DEFAULT_FG_COLOR = '#ffffff';
const NEUTRAL_BG_COLOR = '#000000';
const LEGACY_WHITE_TEXT_COLORS = new Set([
'',
'#330000',
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#6495ED',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#800080',
'#A9A9A9',
'#000000',
]);
const readMetaNumber = (value: unknown): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -24,7 +45,17 @@ const readMetaNumber = (value: unknown): number => {
return 0;
};
const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`;
export const formatLegacyRankingNumber = (value: number, fractionDigits = 0): string =>
new Intl.NumberFormat('en-US', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
useGrouping: true,
}).format(value);
export const resolveLegacyTextColor = (backgroundColor: string): string =>
LEGACY_WHITE_TEXT_COLORS.has(backgroundColor.toUpperCase()) ? '#ffffff' : '#000000';
const percentText = (value: number): string => `${formatLegacyRankingNumber(value * 100, 2)}%`;
const readOwnerDisplayName = (value: unknown): string | null => {
const meta = asRecord(value);
@@ -72,6 +103,7 @@ export const rankingRouter = router({
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
ctx.db.general.findMany({
where: { npcState: npcFilter },
orderBy: { id: 'asc' },
select: {
id: true,
name: true,
@@ -133,11 +165,11 @@ export const rankingRouter = router({
}
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],
['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
['전 력 전 승 률', 'percent', (_g, r) => {
const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0);
if (total < 50) {
@@ -186,17 +218,20 @@ export const rankingRouter = router({
const ranks = rankMap.get(general.id) ?? {};
const value = valueFn(general, ranks);
const nation = nationMap.get(general.nationId) ?? null;
const bgColor =
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
let display = {
id: general.id,
name: general.name,
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
nationName: nation?.name ?? '재야',
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR,
bgColor,
fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
value,
printValue: valueType === 'percent' ? percentText(value) : Math.floor(value).toLocaleString('ko-KR'),
printValue:
valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
};
if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) {
@@ -206,7 +241,7 @@ export const rankingRouter = router({
ownerName: null,
nationName: '???',
bgColor: DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR,
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
picture: null,
imageServer: 0,
};
@@ -268,12 +303,14 @@ export const rankingRouter = router({
})
.map((general) => {
const nation = nationMap.get(general.nationId) ?? null;
const bgColor =
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
return {
id: general.id,
name: general.name,
nationName: nation?.name ?? '재야',
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR,
bgColor,
fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
};
@@ -299,7 +336,7 @@ export const rankingRouter = router({
name: '미발견',
nationName: '-',
bgColor: DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR,
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
picture: null,
imageServer: 0,
},
@@ -398,7 +435,10 @@ export const rankingRouter = router({
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'),
printValue:
type.type === 'percent'
? percentText(row.value)
: formatLegacyRankingNumber(row.value),
serverName: String(aux.serverName ?? ''),
serverIdx: readMetaNumber(aux.serverIdx),
scenarioName: String(aux.scenarioName ?? ''),
+28 -3
View File
@@ -9,6 +9,7 @@ import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.j
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js';
import { formatLegacyRankingNumber, resolveLegacyTextColor } from '../src/router/ranking/index.js';
const profile: GameProfile = {
id: 'che',
@@ -40,7 +41,7 @@ const generalRows = [
npcState: 0,
picture: '1.jpg',
imageServer: 0,
meta: { ownerName: '공개소유자' },
meta: { ownerName: '공개소유자', dex1: 120 },
experience: 1200,
dedication: 900,
horseCode: 'che_명마_15_적토마',
@@ -56,7 +57,7 @@ const generalRows = [
npcState: 1,
picture: null,
imageServer: 0,
meta: { owner_name: '빙의소유자' },
meta: { owner_name: '빙의소유자', dex1: 80 },
experience: 1100,
dedication: 800,
horseCode: 'None',
@@ -72,7 +73,7 @@ const generalRows = [
npcState: 2,
picture: null,
imageServer: 0,
meta: {},
meta: { dex1: 200 },
experience: 1300,
dedication: 1000,
horseCode: 'None',
@@ -122,6 +123,9 @@ const buildContext = (options?: {
{ generalId: 1, type: 'firenum', value: 10 },
{ generalId: 2, type: 'firenum', value: 20 },
{ generalId: 3, type: 'firenum', value: 30 },
{ generalId: 1, type: 'dex1', value: 999 },
{ generalId: 2, type: 'dex1', value: 999 },
{ generalId: 3, type: 'dex1', value: 999 },
],
},
auction: {
@@ -218,6 +222,27 @@ describe('ranking.getBestGeneral', () => {
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' });
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]);
});
it('uses the general dex columns as the legacy source of truth instead of mirrored rank rows', async () => {
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'user' });
const dex = result.sections.find((section) => section.title === '보 병 숙 련 도');
expect(dex?.entries.map((entry) => [entry.id, entry.value, entry.printValue])).toEqual([
[1, 120, '120'],
[2, 80, '80'],
]);
expect(dex?.entries[0]).toMatchObject({
bgColor: '#006400',
fgColor: '#000000',
});
});
it('matches PHP number_format rounding and the legacy fixed color table', () => {
expect(formatLegacyRankingNumber(1.005, 2)).toBe('1.01');
expect(formatLegacyRankingNumber(12345.6, 2)).toBe('12,345.60');
expect(resolveLegacyTextColor('#006400')).toBe('#000000');
expect(resolveLegacyTextColor('#330000')).toBe('#ffffff');
});
});
describe('ranking hall of fame', () => {