diff --git a/app/game-api/src/battleSim/worker.ts b/app/game-api/src/battleSim/worker.ts index 6c5a2ba..7338153 100644 --- a/app/game-api/src/battleSim/worker.ts +++ b/app/game-api/src/battleSim/worker.ts @@ -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 { diff --git a/app/game-api/src/router/diplomacy/index.ts b/app/game-api/src/router/diplomacy/index.ts index 4854dc0..1c9c907 100644 --- a/app/game-api/src/router/diplomacy/index.ts +++ b/app/game-api/src/router/diplomacy/index.ts @@ -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[0], nationId: number) => { const nation = await ctx.db.nation.findUnique({ where: { id: nationId }, @@ -22,7 +20,7 @@ const resolvePermissionLevel = async (ctx: Parameters[0], n return resolveNationPermission(general, nation.meta, true); }; -const mapLetterState = (state: string): z.infer => { +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' }; }), -}); \ No newline at end of file +}); diff --git a/app/game-api/src/router/ranking/index.ts b/app/game-api/src/router/ranking/index.ts index b170a83..b23dccf 100644 --- a/app/game-api/src/router/ranking/index.ts +++ b/app/game-api/src/router/ranking/index.ts @@ -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 ?? ''), diff --git a/app/game-api/test/rankingRouter.test.ts b/app/game-api/test/rankingRouter.test.ts index 7f897a6..84f4f50 100644 --- a/app/game-api/test/rankingRouter.test.ts +++ b/app/game-api/test/rankingRouter.test.ts @@ -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', () => { diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index 71db076..bfcc84f 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -711,7 +711,7 @@ export class GeneralAI { const leadership = this.general.stats.leadership; const strength = Math.max(this.general.stats.strength, 1); const intel = Math.max(this.general.stats.intelligence, 1); - let genType = 0; + let genType: number; if (strength >= intel) { genType = t무장; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts b/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts index 90b00ad..d2a14dd 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/assignments/troopAssignments.ts @@ -38,7 +38,7 @@ export const do부대전방발령 = (ai: GeneralAI) => { const force = ai.nationPolicy.combatForce[leader.id]; let [fromCityId, toCityId] = force; - let targetCityId: number | null = null; + let targetCityId: number | null; if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) { targetCityId = pickRandomCityId(ai, ai.frontCities); } else { diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 996b78e..0462695 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -22,7 +22,7 @@ import { type LogEntryDraft, type MessageRecordDraft, } from '@sammo-ts/logic'; -import { asRecord, type RankDataType } from '@sammo-ts/common'; +import { asRecord } from '@sammo-ts/common'; import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; @@ -33,6 +33,7 @@ import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js'; import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js'; import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js'; +import { buildPersistedRankRows } from './rankData.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; @@ -270,20 +271,6 @@ const toLegacyDatabaseInt = (value: number): number => { return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5); }; -const readRankMetaNumber = (meta: Record, key: string): number => { - const value = meta[key]; - if (typeof value === 'number' && Number.isFinite(value)) { - return toLegacyDatabaseInt(value); - } - if (typeof value === 'string') { - const parsed = Number(value); - if (Number.isFinite(parsed)) { - return toLegacyDatabaseInt(parsed); - } - } - return 0; -}; - const LEGACY_INTEGER_GENERAL_META_KEYS = [ 'leadership_exp', 'strength_exp', @@ -312,72 +299,10 @@ const buildPersistedGeneralMeta = ( return asJson(meta); }; -const buildRankRows = ( - general: ReturnType['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', toLegacyDatabaseInt(general.experience)], - ['dedication', toLegacyDatabaseInt(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')], - ['inherit_earned_dyn', readMeta('inherit_earned_dyn')], - ['inherit_earned_act', readMeta('inherit_earned_act')], - ['inherit_spent_dyn', readMeta('inherit_spent_dyn')], - ]; - - return entries.map(([type, value]) => ({ - generalId: general.id, - nationId: general.nationId, - type, - value, - })); -}; - const buildInitialRankRows = ( general: ReturnType['generals'][number] ): Array<{ generalId: number; nationId: number; type: string; value: number }> => - buildRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 })); + buildPersistedRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 })); const buildGeneralUpdate = ( general: ReturnType['generals'][number] @@ -953,7 +878,7 @@ export const createDatabaseTurnHooks = async ( if (createdGenerals.length > 0 || rankTargets.length > 0) { const rankRows = [ ...createdGenerals.flatMap(buildInitialRankRows), - ...rankTargets.flatMap(buildRankRows), + ...rankTargets.flatMap(buildPersistedRankRows), ]; await Promise.all( rankRows.map((row) => diff --git a/app/game-engine/src/turn/incomeHandler.ts b/app/game-engine/src/turn/incomeHandler.ts index 171efe8..ce2cee5 100644 --- a/app/game-engine/src/turn/incomeHandler.ts +++ b/app/game-engine/src/turn/incomeHandler.ts @@ -108,7 +108,7 @@ const applyIncomeOutcome = ( originOutcome: number ): { next: number; ratio: number; realOutcome: number } => { let next = current + income; - let realOutcome = 0; + let realOutcome: number; if (next < baseResource) { realOutcome = 0; next = baseResource; @@ -139,14 +139,11 @@ const processIncomeForNation = ( const trait = traitMap.get(nation.typeCode) ?? null; const incomeContext = buildNationIncomeContext(nation, trait); - let income = 0; - if (type === 'gold') { - income = getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level); - } else { - income = - getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) + - getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level); - } + const income = + type === 'gold' + ? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) + : getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) + + getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level); const incomeValue = roundResource(income); const originOutcome = getOutcome(100, nationGenerals); diff --git a/app/game-engine/src/turn/rankData.ts b/app/game-engine/src/turn/rankData.ts new file mode 100644 index 0000000..1ddeaf1 --- /dev/null +++ b/app/game-engine/src/turn/rankData.ts @@ -0,0 +1,87 @@ +import { + LEGACY_RANK_DATA_TYPES, + RANK_DATA_TYPES, + rankDataMetaKey, + type LegacyRankDataType, + type RankDataType, +} from '@sammo-ts/common'; + +export interface RankedGeneralState { + id: number; + nationId: number; + experience: number; + dedication: number; + meta: unknown; +} + +export interface PersistedRankRow { + generalId: number; + nationId: number; + type: RankDataType; + value: number; +} + +const asRecord = (value: unknown): Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? { ...(value as Record) } + : {}; +const toLegacyDatabaseInt = (value: number): number => { + if (!Number.isFinite(value)) { + return 0; + } + return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5); +}; + +const readMetaNumber = (meta: Record, key: string): number => { + const value = meta[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return toLegacyDatabaseInt(value); + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? toLegacyDatabaseInt(parsed) : 0; + } + return 0; +}; + +export const rankMetaKey = rankDataMetaKey; + +export const buildPersistedRankRows = (general: RankedGeneralState): PersistedRankRow[] => { + const meta = asRecord(general.meta); + return RANK_DATA_TYPES.map((type) => { + const value = + type === 'experience' + ? toLegacyDatabaseInt(general.experience) + : type === 'dedication' + ? toLegacyDatabaseInt(general.dedication) + : readMetaNumber(meta, rankMetaKey(type)); + return { + generalId: general.id, + nationId: general.nationId, + type, + value, + }; + }); +}; + +export const buildLegacyComparableRankRows = ( + general: RankedGeneralState +): Array => { + const legacyTypes = new Set(LEGACY_RANK_DATA_TYPES); + return buildPersistedRankRows(general).filter( + (row): row is PersistedRankRow & { type: LegacyRankDataType } => legacyTypes.has(row.type) + ); +}; + +export const applyPersistedRankRowsToMeta = ( + rawMeta: Record, + rows: ReadonlyArray<{ type: string; value: number }> +): void => { + const supportedTypes = new Set(RANK_DATA_TYPES); + for (const row of rows) { + if (row.type === 'experience' || row.type === 'dedication' || !supportedTypes.has(row.type)) { + continue; + } + rawMeta[rankMetaKey(row.type as RankDataType)] = row.value; + } +}; diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 6aa81ac..f9de9b0 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -37,7 +37,7 @@ import { } from '@sammo-ts/logic'; import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; -import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { asRecord, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import type { ConstraintContext, StateView } from '@sammo-ts/logic'; @@ -57,6 +57,7 @@ import { buildFrontStatePatches } from './frontStateHandler.js'; import { buildActionContext } from './reservedTurnActionContext.js'; import { GeneralAI, shouldUseAi } from './ai/generalAi.js'; import type { AiReservedTurnProvider } from './ai/types.js'; +import { rankMetaKey } from './rankData.js'; const DEFAULT_ACTION = '휴식'; @@ -227,44 +228,11 @@ const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({ const resetRetiredGeneral = (general: TurnGeneral): TurnGeneral => { const meta = { ...general.meta }; - for (const key of [ - 'firenum', - 'rank_warnum', - 'rank_killnum', - 'rank_deathnum', - 'rank_occupied', - 'rank_killcrew', - 'rank_deathcrew', - 'rank_killcrew_person', - 'rank_deathcrew_person', - 'rank_ttw', - 'rank_ttd', - 'rank_ttl', - 'rank_ttg', - 'rank_ttp', - 'rank_tlw', - 'rank_tld', - 'rank_tll', - 'rank_tlg', - 'rank_tlp', - 'rank_tsw', - 'rank_tsd', - 'rank_tsl', - 'rank_tsg', - 'rank_tsp', - 'rank_tiw', - 'rank_tid', - 'rank_til', - 'rank_tig', - 'rank_tip', - 'rank_betgold', - 'rank_betwin', - 'rank_betwingold', - 'specage', - 'specage2', - ]) { - meta[key] = 0; + for (const type of LEGACY_RANK_DATA_TYPES) { + meta[rankMetaKey(type)] = 0; } + meta.specage = 0; + meta.specage2 = 0; for (let dex = 1; dex <= 5; dex += 1) { const key = `dex${dex}`; meta[key] = Math.round(readMetaNumber(meta, key, 0) * 0.5); diff --git a/app/game-engine/src/turn/unificationHandler.ts b/app/game-engine/src/turn/unificationHandler.ts index b0c3626..85a6afb 100644 --- a/app/game-engine/src/turn/unificationHandler.ts +++ b/app/game-engine/src/turn/unificationHandler.ts @@ -167,7 +167,8 @@ export const createUnificationHandler = (options: { sabotage, dex, unifier, - unifierAward: general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0, + unifierAward: + general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0, }, }, }); @@ -194,15 +195,11 @@ export const createUnificationHandler = (options: { const meta = asRecord(state.meta); const serverId = - typeof meta.serverId === 'string' && meta.serverId.trim() - ? meta.serverId.trim() - : options.profileName; + 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) - : ''; + 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(); @@ -307,14 +304,16 @@ export const createUnificationHandler = (options: { }; 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; - } + const value = + valueType === 'natural' + ? typeName === 'experience' + ? general.experience + : typeName === 'dedication' + ? general.dedication + : (ranks[typeName] ?? 0) + : valueType === 'rank' + ? (ranks[typeName] ?? 0) + : (calcValues[typeName] ?? 0); if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) { continue; @@ -391,9 +390,7 @@ export const createUnificationHandler = (options: { const meta = asRecord(state.meta); const serverId = - typeof meta.serverId === 'string' && meta.serverId.trim() - ? meta.serverId.trim() - : options.profileName; + typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName; const serverName = typeof meta.serverName === 'string' && meta.serverName.trim() ? meta.serverName.trim() @@ -653,30 +650,30 @@ export const createUnificationHandler = (options: { await Promise.all( oldGeneralTargets.map((general) => ((snapshot) => - prisma.oldGeneral.upsert({ - where: { - by_no: { + prisma.oldGeneral.upsert({ + where: { + by_no: { + serverId, + generalNo: general.id, + }, + }, + update: { + owner: general.userId ?? null, + name: general.name, + lastYearMonth: state.currentYear * 100 + state.currentMonth, + turnTime: general.turnTime, + data: snapshot, + }, + create: { serverId, generalNo: general.id, + owner: general.userId ?? null, + name: general.name, + lastYearMonth: state.currentYear * 100 + state.currentMonth, + turnTime: general.turnTime, + data: snapshot, }, - }, - update: { - owner: general.userId ?? null, - name: general.name, - lastYearMonth: state.currentYear * 100 + state.currentMonth, - turnTime: general.turnTime, - data: snapshot, - }, - create: { - serverId, - generalNo: general.id, - owner: general.userId ?? null, - name: general.name, - lastYearMonth: state.currentYear * 100 + state.currentMonth, - turnTime: general.turnTime, - data: snapshot, - }, - }))( { + }))({ ...general, turnTime: general.turnTime.toISOString(), }) diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 42efbb9..8bffaf2 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -315,8 +315,8 @@ async function handleTournamentMatchResult( const attackerG = getRankNumber(attacker, rankKey('g')); const defenderG = getRankNumber(defender, rankKey('g')); - let attackerGDelta = 0; - let defenderGDelta = 0; + let attackerGDelta: number; + let defenderGDelta: number; let attackerW = 0; let attackerD = 0; let attackerL = 0; diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 90fc556..c0948f8 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -32,6 +32,7 @@ import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js'; import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js'; import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldLoadResult } from './types.js'; import { readDiplomacyMeta } from '@sammo-ts/logic'; +import { applyPersistedRankRowsToMeta } from './rankData.js'; interface TurnWorldLoaderOptions { databaseUrl: string; @@ -157,17 +158,6 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => { return parsed.data; }; -const GENERAL_RANK_META_PREFIX_TYPES = new Set([ - 'warnum', - 'killnum', - 'deathnum', - 'occupied', - 'killcrew', - 'deathcrew', - 'killcrew_person', - 'deathcrew_person', -]); - const mapGeneralRow = ( row: TurnEngineGeneralRow, rankRows: readonly TurnEngineRankDataRow[], @@ -181,12 +171,7 @@ const mapGeneralRow = ( item: normalizeCode(row.itemCode), }; const rawMeta = { ...(asTriggerRecord(row.meta) as Record) }; - for (const rank of rankRows) { - if (rank.type === 'experience' || rank.type === 'dedication') { - continue; - } - rawMeta[GENERAL_RANK_META_PREFIX_TYPES.has(rank.type) ? `rank_${rank.type}` : rank.type] = rank.value; - } + applyPersistedRankRowsToMeta(rawMeta, rankRows); const inheritancePoints = Object.fromEntries(inheritanceRows.map((entry) => [entry.key, entry.value])); const itemInventory = readItemInventoryFromMeta(rawMeta, legacySlots); return { diff --git a/app/game-engine/test/databaseCommandQueue.integration.test.ts b/app/game-engine/test/databaseCommandQueue.integration.test.ts index 9912dd2..2510111 100644 --- a/app/game-engine/test/databaseCommandQueue.integration.test.ts +++ b/app/game-engine/test/databaseCommandQueue.integration.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; +import { createGamePostgresConnector } from '@sammo-ts/infra'; +import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra'; import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js'; diff --git a/app/game-engine/test/generalTurnLifecycle.test.ts b/app/game-engine/test/generalTurnLifecycle.test.ts index 9097161..107e121 100644 --- a/app/game-engine/test/generalTurnLifecycle.test.ts +++ b/app/game-engine/test/generalTurnLifecycle.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; +import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; import type { TurnSchedule } from '@sammo-ts/logic'; +import { rankMetaKey } from '../src/turn/rankData.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { createTurnTestHarness } from './helpers/turnTestHarness.js'; @@ -294,6 +296,9 @@ describe('legacy general turn lifecycle', () => { }); it('retires a player general and resets inherited stats and rank state', async () => { + const legacyRankMeta = Object.fromEntries( + LEGACY_RANK_DATA_TYPES.map((type, index) => [rankMetaKey(type), index + 1]) + ); const harness = await createTurnTestHarness({ snapshot: makeSnapshot([ makeGeneral({ @@ -302,11 +307,11 @@ describe('legacy general turn lifecycle', () => { experience: 101, dedication: 81, meta: { + ...legacyRankMeta, killturn: 24, dex1: 101, inherit_lived_month: 10, inherit_active_action: 4, - rank_warnum: 12, }, }), ]), @@ -325,7 +330,9 @@ describe('legacy general turn lifecycle', () => { expect(updated.meta.dex1).toBe(51); expect(updated.meta.inherit_lived_month).toBe(0); expect(updated.meta.inherit_active_action).toBe(0); - expect(updated.meta.rank_warnum).toBe(0); + for (const type of LEGACY_RANK_DATA_TYPES) { + expect(updated.meta[rankMetaKey(type)], type).toBe(0); + } expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired'); }); }); diff --git a/app/game-engine/test/nationTurnCompatibility.test.ts b/app/game-engine/test/nationTurnCompatibility.test.ts index 5608d86..029e3af 100644 --- a/app/game-engine/test/nationTurnCompatibility.test.ts +++ b/app/game-engine/test/nationTurnCompatibility.test.ts @@ -164,7 +164,7 @@ describe('레거시 사령부 턴 실행 호환성', () => { ); }); - it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', () => { + it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', async () => { const updates: Array<{ id: number; patch: Record }> = []; const nations = [ { @@ -188,7 +188,7 @@ describe('레거시 사령부 턴 실행 호환성', () => { }) as never, }); - handler.beforeMonthChanged?.({} as never); + await handler.beforeMonthChanged?.({} as never); expect(updates).toEqual([ { diff --git a/app/game-engine/test/npcNationTechResearch.test.ts b/app/game-engine/test/npcNationTechResearch.test.ts index aa7fc56..a3fc3c3 100644 --- a/app/game-engine/test/npcNationTechResearch.test.ts +++ b/app/game-engine/test/npcNationTechResearch.test.ts @@ -327,5 +327,5 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => { // Nation awards can occur in the same tick and make the general's net // gold delta smaller than the recruitment price. Exact cost scaling is // covered by the unit-set/action contract tests rather than this smoke. - }, 60000); + }, 300_000); }); diff --git a/app/game-engine/test/npcNationUprisingUnification.test.ts b/app/game-engine/test/npcNationUprisingUnification.test.ts index dea8395..214f681 100644 --- a/app/game-engine/test/npcNationUprisingUnification.test.ts +++ b/app/game-engine/test/npcNationUprisingUnification.test.ts @@ -553,5 +553,5 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { } throw error; } - }, 180000); + }, 360_000); }); diff --git a/app/game-engine/test/rankData.test.ts b/app/game-engine/test/rankData.test.ts new file mode 100644 index 0000000..bd0c589 --- /dev/null +++ b/app/game-engine/test/rankData.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { LEGACY_RANK_DATA_TYPES, RANK_DATA_TYPES } from '@sammo-ts/common'; + +import { + applyPersistedRankRowsToMeta, + buildLegacyComparableRankRows, + buildPersistedRankRows, + rankMetaKey, +} from '../src/turn/rankData.js'; + +describe('rank data projection', () => { + it('projects every core row while preserving legacy key and integer semantics', () => { + const rows = buildPersistedRankRows({ + id: 7, + nationId: 2, + experience: 10.5, + dedication: 20.49, + meta: { + rank_warnum: '3.5', + ttw: 4.5, + inherit_earned: '9', + dex1: 12, + }, + }); + + expect(rows).toHaveLength(RANK_DATA_TYPES.length); + expect(rows).toEqual( + expect.arrayContaining([ + { generalId: 7, nationId: 2, type: 'experience', value: 11 }, + { generalId: 7, nationId: 2, type: 'dedication', value: 20 }, + { generalId: 7, nationId: 2, type: 'warnum', value: 4 }, + { generalId: 7, nationId: 2, type: 'ttw', value: 5 }, + { generalId: 7, nationId: 2, type: 'inherit_earned', value: 9 }, + { generalId: 7, nationId: 2, type: 'dex1', value: 12 }, + ]) + ); + expect(buildLegacyComparableRankRows({ + id: 7, + nationId: 2, + experience: 10.5, + dedication: 20.49, + meta: {}, + })).toHaveLength(LEGACY_RANK_DATA_TYPES.length); + }); + + it('loads persisted rows into the same raw and prefixed meta keys used by commands', () => { + const meta: Record = {}; + applyPersistedRankRowsToMeta(meta, [ + { type: 'warnum', value: 3 }, + { type: 'ttw', value: 4 }, + { type: 'inherit_spent', value: 5 }, + { type: 'dex1', value: 6 }, + { type: 'unknown', value: 99 }, + ]); + + expect(meta).toEqual({ + rank_warnum: 3, + ttw: 4, + inherit_spent: 5, + dex1: 6, + }); + expect(rankMetaKey('warnum')).toBe('rank_warnum'); + expect(rankMetaKey('betgold')).toBe('betgold'); + }); +}); diff --git a/app/game-engine/vitest.config.ts b/app/game-engine/vitest.config.ts index 400c273..83852b4 100644 --- a/app/game-engine/vitest.config.ts +++ b/app/game-engine/vitest.config.ts @@ -13,5 +13,7 @@ export default defineConfig({ environment: 'node', globals: true, include: ['test/**/*.test.ts'], + maxWorkers: 4, + testTimeout: 10_000, }, }); diff --git a/app/game-frontend/src/components/battle/BattleGeneralCard.vue b/app/game-frontend/src/components/battle/BattleGeneralCard.vue index 7a84f23..ed7eb05 100644 --- a/app/game-frontend/src/components/battle/BattleGeneralCard.vue +++ b/app/game-frontend/src/components/battle/BattleGeneralCard.vue @@ -3,7 +3,6 @@ import { computed, ref } from 'vue'; import type { BattleSimOptions, GeneralDraft } from '../../utils/battleSimulatorTypes'; interface Props { - general: GeneralDraft; options: BattleSimOptions; mode: 'attacker' | 'defender'; title: string; @@ -11,6 +10,7 @@ interface Props { } const props = defineProps(); +const general = defineModel('general', { required: true }); const emit = defineEmits<{ (event: 'import'): void; diff --git a/app/game-frontend/src/utils/formatLog.ts b/app/game-frontend/src/utils/formatLog.ts index 39ac1e5..f23dd9d 100644 --- a/app/game-frontend/src/utils/formatLog.ts +++ b/app/game-frontend/src/utils/formatLog.ts @@ -24,11 +24,10 @@ export const formatLog = (text?: string): string => { return ''; } - let match: RegExpExecArray | null = null; let lastIndex = 0; const result: string[] = []; - while ((match = logRegex.exec(text)) !== null) { + for (let match = logRegex.exec(text); match !== null; match = logRegex.exec(text)) { const partAll = match[0]; const subPart = match[1]; const index = match.index; @@ -40,9 +39,7 @@ export const formatLog = (text?: string): string => { if (subPart === '/') { result.push(''); } else if (subPart.length === 2) { - result.push( - `` - ); + result.push(``); } else { result.push(``); } diff --git a/app/game-frontend/src/views/BattleSimulatorView.vue b/app/game-frontend/src/views/BattleSimulatorView.vue index e8ced0d..a63e727 100644 --- a/app/game-frontend/src/views/BattleSimulatorView.vue +++ b/app/game-frontend/src/views/BattleSimulatorView.vue @@ -1125,7 +1125,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value); !loading.value && !!options.value); { } }; -const prevOptions = computed(() => - data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? [] -); +const prevOptions = computed(() => data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []); const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR'); @@ -216,12 +214,14 @@ const canRollback = (letter: DiplomacyLetter) => editable.value && data.value?.myNationId === letter.src.nationId && letter.state === 'PROPOSED'; const canDestroy = (letter: DiplomacyLetter) => - editable.value && letter.state === 'ACTIVATED' && (data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId); + editable.value && + letter.state === 'ACTIVATED' && + (data.value?.myNationId === letter.src.nationId || data.value?.myNationId === letter.dest.nationId); const canRenew = (letter: DiplomacyLetter) => letter.state !== 'CANCELLED'; onMounted(() => { - loadLetters(); + void loadLetters(); }); onBeforeUnmount(() => { @@ -270,26 +270,84 @@ onBeforeUnmount(() => {
내용(국가 내 공개)
- - - + + + - - + +
내용(외교권자 전용)
- - - + + + - - + +
@@ -327,7 +385,10 @@ onBeforeUnmount(() => {

이전 문서를 찾을 수 없습니다.

@@ -335,11 +396,24 @@ onBeforeUnmount(() => {
- - + + - +
@@ -565,4 +639,4 @@ onBeforeUnmount(() => { .loading { color: #9aa3b8; } - \ No newline at end of file + diff --git a/app/game-frontend/src/views/NationAffairsView.vue b/app/game-frontend/src/views/NationAffairsView.vue index 34ccff6..4e3e57e 100644 --- a/app/game-frontend/src/views/NationAffairsView.vue +++ b/app/game-frontend/src/views/NationAffairsView.vue @@ -242,7 +242,7 @@ watch( ); onMounted(() => { - loadData(); + void loadData(); }); onBeforeUnmount(() => { @@ -278,19 +278,17 @@ onBeforeUnmount(() => {

국가 방침

- - - + + +
-
@@ -376,7 +416,9 @@ onBeforeUnmount(() => { /> 전쟁 금지 - 잔여 {{ data.warSettingCnt.remain }}회 (월 +{{ data.warSettingCnt.inc }}회) + 잔여 {{ data.warSettingCnt.remain }}회 (월 +{{ data.warSettingCnt.inc }}회)
@@ -574,4 +616,4 @@ onBeforeUnmount(() => { .loading { color: #9aa3b8; } - \ No newline at end of file + diff --git a/app/game-frontend/src/views/ScoutMessageView.vue b/app/game-frontend/src/views/ScoutMessageView.vue index 038604a..1f7fc5b 100644 --- a/app/game-frontend/src/views/ScoutMessageView.vue +++ b/app/game-frontend/src/views/ScoutMessageView.vue @@ -134,7 +134,7 @@ watch( ); onMounted(() => { - loadData(); + void loadData(); }); onBeforeUnmount(() => { @@ -168,7 +168,11 @@ onBeforeUnmount(() => {
-