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/auction/index.ts b/app/game-api/src/router/auction/index.ts index 5b2f30d..8f1ac28 100644 --- a/app/game-api/src/router/auction/index.ts +++ b/app/game-api/src/router/auction/index.ts @@ -594,7 +594,7 @@ export const auctionRouter = router({ auctionId: auction.id, generalId: general.id, amount: input.amount, - tryExtendCloseDate: input.tryExtendCloseDate ?? true, + tryExtendCloseDate: input.tryExtendCloseDate ?? false, }); if (!result || result.type !== 'auctionBid') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); 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/public/index.ts b/app/game-api/src/router/public/index.ts index f4ef4d8..cfde31a 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -1,13 +1,14 @@ import { TRPCError } from '@trpc/server'; import { asRecord } from '@sammo-ts/common'; +import { z } from 'zod'; import type { GameApiContext } from '../../context.js'; import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; import { loadPublicMap } from '../../maps/worldMap.js'; -import { procedure, router } from '../../trpc.js'; +import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js'; +import { procedure, router, sessionActivityProcedure } from '../../trpc.js'; import { loadTraitNames } from '../nation/shared.js'; -import { z } from 'zod'; type WorldTrendSnapshot = { year: number; @@ -231,6 +232,11 @@ const sortNpcList = ({ + recorded: await recordGeneralAccess(ctx, input.page), + })), getMapLayout: procedure.query(async ({ ctx }) => { return loadMapLayout(ctx.profile.scenario); }), 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/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts new file mode 100644 index 0000000..ecf03f7 --- /dev/null +++ b/app/game-api/src/services/generalAccess.ts @@ -0,0 +1,184 @@ +import { asRecord } from '@sammo-ts/common'; +import { GamePrisma } from '@sammo-ts/infra'; + +import type { GameApiContext } from '../context.js'; + +export const accessPages = [ + 'front-info', + 'nation-info', + 'nation-cities', + 'global-info', + 'current-city', + 'diplomacy', + 'nation-generals', + 'nation-personnel', + 'nation-finance', + 'battle-center', + 'board', + 'best-general', + 'hall-of-fame', + 'dynasty', + 'yearbook', + 'nation-betting', + 'traffic', + 'npc-list', + 'my-page', + 'npc-control', + 'tournament', + 'betting', +] as const; + +export type AccessPage = (typeof accessPages)[number]; + +export const accessPageWeights: Record = { + 'front-info': 1, + 'nation-info': 1, + 'nation-cities': 1, + 'global-info': 1, + 'current-city': 1, + diplomacy: 1, + 'nation-generals': 1, + 'nation-personnel': 1, + 'nation-finance': 1, + 'battle-center': 1, + board: 1, + 'best-general': 1, + 'hall-of-fame': 1, + dynasty: 1, + yearbook: 1, + 'nation-betting': 1, + traffic: 1, + 'npc-list': 2, + 'my-page': 1, + 'npc-control': 1, + tournament: 1, + betting: 1, +}; + +const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']); + +const readFiniteNumber = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +const readDate = (value: unknown): Date | null => { + if (typeof value !== 'string' && !(value instanceof Date)) { + return null; + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + +export const resolveAccessWindows = ( + now: Date, + tickSeconds: number, + worldMeta: unknown +): { dayStartedAt: Date; scoreStartedAt: Date } => { + const dayStartedAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const meta = asRecord(worldMeta); + const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime); + const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000; + const scoreStartedAt = + tickStartedAt && tickStartedAt.getTime() <= now.getTime() + ? tickStartedAt + : new Date(now.getTime() - fallbackTickMs); + return { dayStartedAt, scoreStartedAt }; +}; + +export const upsertGeneralAccess = async ( + db: Pick, + input: { + generalId: number; + userId: string; + weight: number; + now: Date; + dayStartedAt: Date; + scoreStartedAt: Date; + } +): Promise => { + await db.$executeRaw( + GamePrisma.sql` + INSERT INTO general_access_log ( + general_id, + user_id, + last_refresh, + refresh, + refresh_total, + refresh_score, + refresh_score_total + ) + VALUES ( + ${input.generalId}, + ${input.userId}, + ${input.now}, + ${input.weight}, + ${input.weight}, + ${input.weight}, + ${input.weight} + ) + ON CONFLICT (general_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + last_refresh = EXCLUDED.last_refresh, + refresh = CASE + WHEN general_access_log.last_refresh IS NULL + OR general_access_log.last_refresh < ${input.dayStartedAt} + THEN EXCLUDED.refresh + ELSE general_access_log.refresh + EXCLUDED.refresh + END, + refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total, + refresh_score = CASE + WHEN general_access_log.last_refresh IS NULL + OR general_access_log.last_refresh < ${input.scoreStartedAt} + THEN EXCLUDED.refresh_score + ELSE general_access_log.refresh_score + EXCLUDED.refresh_score + END, + refresh_score_total = + general_access_log.refresh_score_total + EXCLUDED.refresh_score_total + ` + ); +}; + +export const recordGeneralAccess = async ( + ctx: Pick, + page: AccessPage, + now = new Date() +): Promise => { + const user = ctx.auth?.user; + if (!user || user.roles.some((role) => adminRoles.has(role))) { + return false; + } + + const [general, worldState] = await Promise.all([ + ctx.db.general.findFirst({ + where: { userId: user.id }, + orderBy: { id: 'asc' }, + select: { id: true, userId: true }, + }), + ctx.db.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { tickSeconds: true, meta: true }, + }), + ]); + if (!general || !worldState) { + return false; + } + + const meta = asRecord(worldState.meta); + const isUnited = readFiniteNumber(meta.isUnited) ?? readFiniteNumber(meta.isunited) ?? 0; + const openTime = readDate(meta.opentime); + if (isUnited === 2 || (openTime && openTime.getTime() > now.getTime())) { + return false; + } + + const weight = accessPageWeights[page]; + const { dayStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta); + + await upsertGeneralAccess(ctx.db, { + generalId: general.id, + userId: user.id, + weight, + now, + dayStartedAt, + scoreStartedAt, + }); + return true; +}; diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 4a3e8dc..880fa42 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -63,6 +63,10 @@ export const router = t.router; export const procedure = t.procedure.use(inputEventMiddleware); export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware); +// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조 +// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다. +export const sessionActivityProcedure = t.procedure; + // 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과 // 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다. export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index f91041d..2e5c85b 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -205,6 +205,7 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0), generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0), generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500), + npcSeizureMessageProb: resolveNumber(constValues, ['npcSeizureMessageProb'], 0.01), maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0), }; }; diff --git a/app/game-api/test/auctionRouter.test.ts b/app/game-api/test/auctionRouter.test.ts new file mode 100644 index 0000000..fb915be --- /dev/null +++ b/app/game-api/test/auctionRouter.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { GamePrisma, RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 7, + userId: 'user-1', + name: '유비', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 50, + strength: 50, + intel: 50, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 1, + gold: 10_000, + rice: 10_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-07-26T00:00:00Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-07-26T00:00:00Z'), + updatedAt: new Date('2026-07-26T00:00:00Z'), + ...overrides, +}); + +const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: `session-${userId}`, + user: { + id: userId, + username: userId, + displayName: userId, + roles: [], + }, + sanctions: {}, +}); + +const sqlText = (query: GamePrisma.Sql): string => query.strings.join(' '); + +const buildContext = (options: { + auth?: GameSessionTokenPayload | null; + general?: GeneralRow | null; + auctions?: Array>; + queryRaw?: (query: GamePrisma.Sql) => Promise; +}) => { + const auth = options.auth === undefined ? buildAuth() : options.auth; + const general = options.general === undefined ? buildGeneral() : options.general; + const requestCommand = vi.fn(async (command: { type: string }) => { + if (command.type === 'auctionOpen') { + return { + type: 'auctionOpen' as const, + ok: true as const, + auctionId: 91, + closeAt: '2026-07-27T00:00:00.000Z', + }; + } + return { + type: 'auctionBid' as const, + ok: true as const, + auctionId: 91, + closeAt: '2026-07-27T00:00:00.000Z', + }; + }); + const queryRaw = vi.fn(options.queryRaw ?? (async () => [])); + const worldState = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 3600, + config: { + const: { + auctionName: ['청룡', '백호', '주작', '현무'], + allItems: { weapon: { che_무기_12_칠성검: 1 } }, + }, + }, + meta: { hiddenSeed: 'auction-hidden-seed' }, + updatedAt: new Date('2026-07-26T00:00:00Z'), + }; + const db = { + $queryRaw: queryRaw, + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + general?.userId === where.userId ? general : null + ), + findMany: vi.fn(async ({ where }: { where: { id: { in: number[] } } }) => + where.id.in.map((id) => ({ id, name: id === 88 ? '관우' : '조조' })) + ), + }, + auction: { + findMany: vi.fn(async () => options.auctions ?? []), + findFirst: vi.fn(async () => null), + }, + worldState: { + findFirst: vi.fn(async () => worldState), + }, + inheritancePoint: { + findUnique: vi.fn(async () => ({ value: 10_000 })), + }, + logEntry: { + findMany: vi.fn(async () => []), + }, + }; + const redis = { + zAdd: vi.fn(async () => 1), + }; + const accessTokenStore = new RedisAccessTokenStore( + { + get: async () => null, + set: async () => null, + }, + 'che:default' + ); + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: redis as unknown as RedisConnector['client'], + turnDaemon: { requestCommand } as unknown as TurnDaemonTransport, + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, db, queryRaw, redis, requestCommand }; +}; + +describe('auction router actor and permission boundaries', () => { + it('rejects unauthenticated auction reads', async () => { + const fixture = buildContext({ auth: null }); + + await expect(appRouter.createCaller(fixture.context).auction.getOverview()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('rejects reads and mutations when the authenticated user owns no general', async () => { + const fixture = buildContext({ + auth: buildAuth('user-2'), + general: buildGeneral({ userId: 'user-1' }), + }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.auction.getOverview()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: 'General not found.', + }); + await expect( + caller.auction.openBuyRice({ + amount: 1000, + closeTurnCnt: 3, + startBidAmount: 500, + finishBidAmount: 2000, + }) + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: 'General not found.', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('derives the daemon actor from the session-owned general and ignores a forged generalId field', async () => { + const fixture = buildContext({ general: buildGeneral({ id: 7, userId: 'user-1' }) }); + const input = { + amount: 1000, + closeTurnCnt: 3, + startBidAmount: 500, + finishBidAmount: 2000, + generalId: 999, + }; + + await appRouter.createCaller(fixture.context).auction.openBuyRice(input); + + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'auctionOpen', + auctionType: 'BUY_RICE', + generalId: 7, + amount: 1000, + closeTurnCnt: 3, + startBidAmount: 500, + finishBidAmount: 2000, + }); + }); + + it('redacts real unique-auction identities while preserving caller markers', async () => { + const openedAt = new Date('2026-07-26T01:00:00Z'); + const fixture = buildContext({ + auctions: [ + { + id: 31, + type: 'UNIQUE_ITEM', + targetCode: 'che_무기_12_칠성검', + hostGeneralId: 7, + hostName: null, + detail: { title: '칠성검 경매', startBidAmount: 5000 }, + status: 'OPEN', + closeAt: new Date('2026-07-27T00:00:00Z'), + bids: [ + { + id: 41, + generalId: 88, + amount: 5500, + eventAt: openedAt, + }, + ], + }, + ], + }); + + const result = await appRouter.createCaller(fixture.context).auction.getOverview(); + const unique = result.uniqueAuctions[0]; + + expect(unique).toMatchObject({ + id: 31, + hostGeneralId: null, + isCallerHost: true, + highestBid: { amount: 5500, isCaller: false }, + }); + expect(unique?.hostName).not.toBe('유비'); + expect(unique?.highestBid?.bidderName).not.toBe('관우'); + expect(JSON.stringify(unique)).not.toContain('"generalId"'); + expect(JSON.stringify(unique)).not.toContain('"hostGeneralId":7'); + }); + + it('keeps the legacy default of no requested close extension for a unique bid', async () => { + const fixture = buildContext({ + queryRaw: async (query) => { + const text = sqlText(query); + if (text.includes('FROM auction') && text.includes('WHERE id =')) { + return [ + { + id: 31, + type: 'UNIQUE_ITEM', + targetCode: 'che_무기_12_칠성검', + hostGeneralId: 88, + detail: { startBidAmount: 100, isReverse: false }, + status: 'OPEN', + closeAt: new Date('2026-07-27T00:00:00Z'), + }, + ]; + } + if (text.includes('FROM auction_bid') && text.includes('general_id =')) { + return []; + } + if (text.includes('SELECT bid.auction_id')) { + return [{ auctionId: 31, generalId: 88, amount: 100 }]; + } + if (text.includes('FROM auction_bid')) { + return [{ id: 41, generalId: 88, amount: 100, meta: {} }]; + } + if (text.includes('SELECT id, target_code')) { + return [{ id: 31, targetCode: 'che_무기_12_칠성검' }]; + } + return []; + }, + }); + + await appRouter.createCaller(fixture.context).auction.bidUnique({ + auctionId: 31, + amount: 110, + }); + + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'auctionBid', + auctionId: 31, + generalId: 7, + amount: 110, + tryExtendCloseDate: false, + }); + }); +}); diff --git a/app/game-api/test/generalAccessTracking.integration.test.ts b/app/game-api/test/generalAccessTracking.integration.test.ts new file mode 100644 index 0000000..989792f --- /dev/null +++ b/app/game-api/test/generalAccessTracking.integration.test.ts @@ -0,0 +1,71 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { upsertGeneralAccess } from '../src/services/generalAccess.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const generalId = 9_980_071; + +integration('general access tracking persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.generalAccessLog.deleteMany({ where: { generalId } }); + }); + + afterAll(async () => { + await db.generalAccessLog.deleteMany({ where: { generalId } }); + await closeDb?.(); + }); + + it('atomically increments concurrent requests and resets only windowed counters', async () => { + const firstWindow = { + generalId, + userId: 'access-user-a', + now: new Date('2026-07-26T03:05:00.000Z'), + dayStartedAt: new Date('2026-07-26T00:00:00.000Z'), + scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'), + }; + await upsertGeneralAccess(db, { ...firstWindow, weight: 2 }); + await Promise.all( + Array.from({ length: 20 }, (_, index) => + upsertGeneralAccess(db, { + ...firstWindow, + now: new Date(firstWindow.now.getTime() + index + 1), + weight: 1, + }) + ) + ); + + expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({ + userId: 'access-user-a', + refresh: 22, + refreshTotal: 22, + refreshScore: 22, + refreshScoreTotal: 22, + }); + + await upsertGeneralAccess(db, { + generalId, + userId: 'access-user-b', + now: new Date('2026-07-27T00:05:00.000Z'), + dayStartedAt: new Date('2026-07-27T00:00:00.000Z'), + scoreStartedAt: new Date('2026-07-27T00:00:00.000Z'), + weight: 1, + }); + + expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({ + userId: 'access-user-b', + refresh: 1, + refreshTotal: 23, + refreshScore: 1, + refreshScoreTotal: 23, + }); + }); +}); diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts new file mode 100644 index 0000000..e0c32ae --- /dev/null +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { DatabaseClient } from '../src/context.js'; + +import { recordGeneralAccess, resolveAccessWindows } from '../src/services/generalAccess.js'; + +const auth = (roles = ['user']): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: 'access-session', + user: { + id: 'user-7', + username: 'user7', + displayName: '사용자7', + roles, + }, + sanctions: {}, +}); + +const buildDb = (meta: Record = {}) => { + const executeRaw = vi.fn(async (_query: unknown) => 1); + const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' })); + const findWorld = vi.fn(async () => ({ + tickSeconds: 600, + meta: { + opentime: '2026-07-25T00:00:00.000Z', + lastTurnTime: '2026-07-26T03:00:00.000Z', + ...meta, + }, + })); + const db = { + $executeRaw: executeRaw, + general: { findFirst: findGeneral }, + worldState: { findFirst: findWorld }, + } as unknown as DatabaseClient; + return { db, executeRaw, findGeneral, findWorld }; +}; + +describe('general access tracking', () => { + it('resolves the UTC day and latest processed turn windows', () => { + expect( + resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, { + lastTurnTime: '2026-07-26T03:10:00.000Z', + }) + ).toEqual({ + dayStartedAt: new Date('2026-07-26T00:00:00.000Z'), + scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'), + }); + }); + + it('uses the session user actor and the legacy page weight in one atomic upsert', async () => { + const { db, executeRaw, findGeneral } = buildDb(); + const now = new Date('2026-07-26T03:05:00.000Z'); + + await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true); + expect(findGeneral).toHaveBeenCalledWith({ + where: { userId: 'user-7' }, + orderBy: { id: 'asc' }, + select: { id: true, userId: true }, + }); + expect(executeRaw).toHaveBeenCalledTimes(1); + + const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; + expect(statement.sql).toContain('ON CONFLICT (general_id) DO UPDATE'); + expect(statement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh'); + expect(statement.values).toEqual([ + 7, + 'user-7', + now, + 2, + 2, + 2, + 2, + new Date('2026-07-26T00:00:00.000Z'), + new Date('2026-07-26T03:00:00.000Z'), + ]); + }); + + it('does not write for anonymous/admin users, a future opening, or a finished world', async () => { + const anonymous = buildDb(); + await expect(recordGeneralAccess({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false); + expect(anonymous.findGeneral).not.toHaveBeenCalled(); + + const admin = buildDb(); + await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, 'traffic')).resolves.toBe(false); + expect(admin.findGeneral).not.toHaveBeenCalled(); + + const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' }); + await expect( + recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z')) + ).resolves.toBe(false); + expect(future.executeRaw).not.toHaveBeenCalled(); + + const united = buildDb({ isUnited: 2 }); + await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false); + expect(united.executeRaw).not.toHaveBeenCalled(); + }); +}); 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/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index b3d5691..142eec8 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -37,6 +37,7 @@ const DEFAULT_BASE_GOLD = 0; const DEFAULT_BASE_RICE = 2000; const DEFAULT_GENERAL_MINIMUM_GOLD = 0; const DEFAULT_GENERAL_MINIMUM_RICE = 500; +const DEFAULT_NPC_SEIZURE_MESSAGE_PROB = 0.01; const DEFAULT_MAX_RESOURCE_ACTION_AMOUNT = 10000; const normalizeCode = (value: string | null | undefined): string | null => { @@ -136,6 +137,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE), generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], DEFAULT_GENERAL_MINIMUM_GOLD), generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], DEFAULT_GENERAL_MINIMUM_RICE), + npcSeizureMessageProb: resolveNumber(constValues, ['npcSeizureMessageProb'], DEFAULT_NPC_SEIZURE_MESSAGE_PROB), maxResourceActionAmount: resolveNumber( constValues, ['maxResourceActionAmount'], 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/e2e/auction.spec.ts b/app/game-frontend/e2e/auction.spec.ts new file mode 100644 index 0000000..7267ece --- /dev/null +++ b/app/game-frontend/e2e/auction.spec.ts @@ -0,0 +1,371 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game')]; + +type AuctionFixture = { + failResourceBid?: boolean; + resourceBidCount: number; + uniqueBidCount: number; +}; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'BAD_REQUEST', httpStatus: 400, path }, + }, +}); + +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const readReferenceImage = async (filename: string): Promise => { + for (const imageRoot of imageRoots) { + try { + return await readFile(resolve(imageRoot, filename)); + } catch { + // The main checkout and nested feature worktrees have different parents. + } + } + throw new Error(`Reference image not found: ${filename}`); +}; + +const overview = { + resourceAuctions: [ + { + id: 1, + type: 'BUY_RICE', + targetCode: '1000', + status: 'OPEN', + hostGeneralId: 11, + hostName: '조조', + isCallerHost: false, + closeAt: '2026-07-27T02:30:00.000Z', + detail: { + title: '쌀 1000 경매', + amount: 1000, + isReverse: false, + startBidAmount: 500, + finishBidAmount: 1800, + }, + highestBid: { + amount: 750, + bidderName: '관우', + isCaller: false, + eventAt: '2026-07-26T01:00:00.000Z', + }, + }, + { + id: 2, + type: 'SELL_RICE', + targetCode: '900', + status: 'OPEN', + hostGeneralId: 7, + hostName: '유비', + isCallerHost: true, + closeAt: '2026-07-27T03:00:00.000Z', + detail: { + title: '금 900 경매', + amount: 900, + isReverse: false, + startBidAmount: 600, + finishBidAmount: 1700, + }, + highestBid: null, + }, + ], + uniqueAuctions: [ + { + id: 10, + type: 'UNIQUE_ITEM', + targetCode: 'che_무기_12_칠성검', + status: 'OPEN', + hostGeneralId: null, + hostName: '청룡', + isCallerHost: false, + closeAt: '2026-07-27T04:00:00.000Z', + detail: { + title: '칠성검 경매', + startBidAmount: 5000, + remainCloseDateExtensionCnt: 1, + availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z', + }, + highestBid: { + amount: 5500, + bidderName: '백호', + isCaller: false, + eventAt: '2026-07-26T02:00:00.000Z', + }, + }, + { + id: 9, + type: 'UNIQUE_ITEM', + targetCode: 'che_서적_15_손자병법', + status: 'FINISHED', + hostGeneralId: null, + hostName: '현무', + isCallerHost: true, + closeAt: '2026-07-25T04:00:00.000Z', + detail: { + title: '손자병법 경매', + startBidAmount: 5000, + remainCloseDateExtensionCnt: 0, + availableLatestBidCloseDate: '2026-07-25T04:30:00.000Z', + }, + highestBid: { + amount: 6000, + bidderName: '현무', + isCaller: true, + eventAt: '2026-07-25T03:00:00.000Z', + }, + }, + ], + callerAlias: '현무', + remainPoint: 9000, + recentLogs: [ + { + id: 1, + text: '●경매 1번 거래가 성사되었습니다.', + createdAt: '2026-07-25T00:00:00.000Z', + }, + ], +}; + +const uniqueDetail = { + auction: { + id: 10, + targetCode: 'che_무기_12_칠성검', + status: 'OPEN', + hostName: '청룡', + isCallerHost: false, + closeAt: '2026-07-27T04:00:00.000Z', + detail: { + title: '칠성검 경매', + startBidAmount: 5000, + remainCloseDateExtensionCnt: 1, + availableLatestBidCloseDate: '2026-07-27T04:30:00.000Z', + }, + }, + bids: [ + { + id: 101, + amount: 5500, + bidderName: '백호', + isCaller: false, + eventAt: '2026-07-26T02:00:00.000Z', + }, + { + id: 100, + amount: 5000, + bidderName: '현무', + isCaller: true, + eventAt: '2026-07-26T01:00:00.000Z', + }, + ], + callerAlias: '현무', + remainPoint: 9000, +}; + +const installFixture = async (page: Page, state: AuctionFixture) => { + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_auction_playwright'); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }); + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readReferenceImage(filename), + }); + }); + } + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'lobby.info') { + return response({ myGeneral: { id: 7, name: '유비' } }); + } + if (operation === 'join.getConfig') { + return response({}); + } + if (operation === 'auction.getOverview') { + return response(overview); + } + if (operation === 'auction.getUniqueDetail') { + return response(uniqueDetail); + } + if (operation === 'auction.bidBuyRice') { + if (state.failResourceBid) { + state.failResourceBid = false; + return errorResponse(operation, '금이 부족합니다.'); + } + state.resourceBidCount += 1; + return response({ ok: true }); + } + if (operation === 'auction.bidUnique') { + state.uniqueBidCount += 1; + return response({ ok: true }); + } + if (operation === 'auction.openBuyRice' || operation === 'auction.openSellRice') { + return response({ auctionId: 20, closeAt: '2026-07-28T00:00:00.000Z' }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +const gotoAuction = async (page: Page, suffix = 'auction') => { + const lobbyResponse = page.waitForResponse((response) => response.url().includes('/trpc/lobby.info')); + await page.goto(suffix); + await lobbyResponse; + await expect(page.locator('#container')).toBeVisible(); +}; + +test('resource auction preserves the legacy desktop structure, geometry, and interaction states', async ({ page }) => { + const state = { failResourceBid: true, resourceBidCount: 0, uniqueBidCount: 0 }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 800 }); + await gotoAuction(page); + await expect(page.getByRole('heading', { name: '경매장', exact: true })).toBeVisible(); + await expect(page.getByText('쌀 구매', { exact: true })).toBeVisible(); + await expect(page.getByText('쌀 판매', { exact: true })).toBeVisible(); + await expect(page.getByText('단가', { exact: true }).first()).toBeVisible(); + + const geometry = await page.locator('#container').evaluate((container) => { + const box = (selector: string) => { + const rect = container.querySelector(selector)!.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }; + const containerRect = container.getBoundingClientRect(); + const row = container.querySelector('.resource-row')!; + const rowRect = row.getBoundingClientRect(); + const cells = [...row.children].map((cell) => cell.getBoundingClientRect().width); + const button = container.querySelector('.tab-button')!; + const buttonStyle = getComputedStyle(button); + return { + container: { x: containerRect.x, width: containerRect.width }, + topBar: box('.top-back-bar'), + row: { width: rowRect.width, height: rowRect.height }, + cells, + button: { + height: button.getBoundingClientRect().height, + borderRadius: buttonStyle.borderRadius, + cursor: buttonStyle.cursor, + fontSize: buttonStyle.fontSize, + }, + }; + }); + expect(geometry.container).toEqual({ x: 0, width: 1000 }); + expect(geometry.topBar).toMatchObject({ x: 0, width: 1000, height: 32 }); + expect(geometry.row).toEqual({ width: 1000, height: 22 }); + expect(geometry.cells[0]).toBeCloseTo(66.66, 1); + expect(geometry.cells[1]).toBeCloseTo(133.34, 1); + expect(geometry.cells[6]).toBeCloseTo(200, 1); + expect(geometry.button).toEqual({ + height: 35.5, + borderRadius: '5.25px', + cursor: 'pointer', + fontSize: '14px', + }); + await page.screenshot({ path: 'test-results/auction/resource-desktop-initial.png', fullPage: true }); + + const firstRow = page.locator('.resource-row.clickable-row').first(); + await firstRow.click(); + const bidInput = page.getByRole('spinbutton', { name: '1번 경매 입찰가' }); + await bidInput.fill('800'); + await page.getByRole('button', { name: '입찰', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('금이 부족합니다.'); + await page.screenshot({ path: 'test-results/auction/resource-desktop-error.png', fullPage: true }); + expect(state.resourceBidCount).toBe(0); + + await page.getByRole('button', { name: '입찰', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('입찰했습니다.'); + expect(state.resourceBidCount).toBe(1); + + await firstRow.hover(); + expect(await firstRow.evaluate((row) => getComputedStyle(row).cursor)).toBe('pointer'); + await page.screenshot({ path: 'test-results/auction/resource-desktop.png', fullPage: true }); +}); + +test('resource auction keeps the legacy 500px two-row grid', async ({ page }) => { + await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 }); + await page.setViewportSize({ width: 500, height: 800 }); + await gotoAuction(page); + + const geometry = await page + .locator('.resource-row') + .first() + .evaluate((row) => { + const origin = row.getBoundingClientRect(); + const relative = (selector: string) => { + const rect = row.querySelector(selector)!.getBoundingClientRect(); + return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height }; + }; + return { + row: { width: origin.width, height: origin.height }, + idx: relative('.idx'), + host: relative('.host'), + amount: relative('.amount'), + close: relative('.close-date'), + }; + }); + expect(geometry.row).toEqual({ width: 500, height: 43 }); + expect(geometry.idx).toEqual({ x: 0, y: 10.5, width: 41.65625, height: 21 }); + expect(geometry.host).toEqual({ x: 41.65625, y: 0, width: 125, height: 21 }); + expect(geometry.amount).toEqual({ x: 41.65625, y: 21, width: 125, height: 21 }); + expect(geometry.close).toEqual({ x: 416.65625, y: 10.5, width: 83.34375, height: 21 }); + await page.screenshot({ path: 'test-results/auction/resource-mobile.png', fullPage: true }); +}); + +test('unique auction separates ongoing and finished lists and auto-loads the legacy detail', async ({ page }) => { + const state = { resourceBidCount: 0, uniqueBidCount: 0 }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 800 }); + await gotoAuction(page, 'auction?type=unique'); + + await expect(page.getByRole('heading', { name: '유니크 경매장', exact: true })).toBeVisible(); + await expect(page.locator('.caller-alias')).toContainText('내 가명: 현무'); + await expect(page.getByRole('heading', { name: '경매 10번 상세' })).toBeVisible(); + await expect(page.getByText('최대지연', { exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: '진행중인 경매 목록' })).toBeVisible(); + await expect(page.getByRole('heading', { name: '종료된 경매 목록' })).toBeVisible(); + await expect(page.getByText('남음', { exact: true })).toBeVisible(); + await expect(page.getByText('소진', { exact: true })).toBeVisible(); + + const aliasStyle = await page.locator('.caller-alias strong').evaluate((element) => { + const style = getComputedStyle(element); + return { color: style.color, fontWeight: style.fontWeight }; + }); + expect(aliasStyle).toEqual({ color: 'rgb(0, 255, 255)', fontWeight: '700' }); + + const input = page.getByRole('spinbutton', { name: '유산포인트' }); + await input.fill('5600'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('칠성검 경매에 5600유산포인트를 입찰하시겠습니까?'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '입찰', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('입찰이 완료되었습니다.'); + expect(state.uniqueBidCount).toBe(1); + await page.screenshot({ path: 'test-results/auction/unique-desktop.png', fullPage: true }); +}); + +test('resource host cannot bid on the auction opened by its own general', async ({ page }) => { + await installFixture(page, { resourceBidCount: 0, uniqueBidCount: 0 }); + await gotoAuction(page); + + await page.locator('.resource-row.clickable-row').filter({ hasText: '유비' }).click(); + await expect(page.getByRole('button', { name: '입찰', exact: true })).toBeDisabled(); +}); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 8d7a3f2..6d76291 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -23,6 +23,12 @@ type FixtureState = { permission: 'head' | 'member'; myset: number; settingMutations: Array>; + accessPages: string[]; +}; + +type TrpcRequestPayload = { + json?: Record; + input?: { json?: Record }; }; const myGeneral = (state: FixtureState) => ({ @@ -113,7 +119,7 @@ const battleCenter = (state: FixtureState) => ({ const install = async (page: Page, state: FixtureState) => { await page.addInitScript(() => { - localStorage.setItem('sammo-game-token', 'menu-token'); + localStorage.setItem('sammo-game-token', 'ga_menu-token'); localStorage.setItem('sammo-game-profile', 'che:default'); }); await page.route('**/image/game/**', async (route) => { @@ -130,7 +136,16 @@ const install = async (page: Page, state: FixtureState) => { }); await page.route('**/che/api/trpc/**', async (route) => { const operations = operationNames(route); - const results = operations.map((operation) => { + const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {}; + const requestBody = + rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record) : {}; + const results = operations.map((operation, operationIndex) => { + const rawPayload = + requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined); + const payload = + rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined; + const jsonInput = + payload?.json ?? payload?.input?.json ?? (payload as Record | undefined) ?? {}; if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } }); if (operation === 'join.getConfig') return response({}); if (operation === 'general.me') return response(myGeneral(state)); @@ -162,11 +177,15 @@ const install = async (page: Page, state: FixtureState) => { if (operation === 'general.getMyLog') return response({ type: 'generalAction', logs: [{ id: 1, text: '기록' }] }); if (operation === 'general.setMySetting') { - const raw = route.request().postDataJSON() as { input?: { json?: Record } }; - state.settingMutations.push(raw.input?.json ?? {}); + state.settingMutations.push(jsonInput); state.myset = Math.max(0, state.myset - 1); return response({ ok: true }); } + if (operation === 'public.recordAccess') { + const pageName = typeof jsonInput.page === 'string' ? jsonInput.page : null; + if (pageName) state.accessPages.push(pageName); + return response({ recorded: true }); + } if (operation === 'nation.getBattleCenter') { if (state.permission === 'member') { return { @@ -196,11 +215,12 @@ const install = async (page: Page, state: FixtureState) => { }; test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => { - const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [] }; + const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] }; await install(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('traffic'); await expect(page.locator('.chart-title').first()).toHaveText('접 속 량'); + await expect.poll(() => state.accessPages).toContain('traffic'); const geometry = await page.locator('#traffic-container').evaluate((element) => { const rect = element.getBoundingClientRect(); @@ -244,12 +264,13 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p }); test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => { - const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('my-page'); await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('#set_my_setting')).toBeVisible(); + await expect.poll(() => state.accessPages).toContain('my-page'); const desktop = await page.locator('#container').evaluate((element) => { const rect = element.getBoundingClientRect(); @@ -322,7 +343,7 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac }); test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => { - const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; + const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(page, head); await page.setViewportSize({ width: 1000, height: 900 }); await page.goto('battle-center'); @@ -368,7 +389,7 @@ test('감찰부 keeps the selector interaction and shows the permission error pa await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry); await page.unrouteAll({ behavior: 'wait' }); - const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [] }; + const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [], accessPages: [] }; await install(page, member); await page.reload(); await expect(page.locator('.error')).toContainText('권한이 부족합니다.'); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 5ef6863..ea02b56 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -16,6 +16,7 @@ export default defineConfig({ 'nationOffices.spec.ts', 'nationGeneralSecret.spec.ts', 'npcPolicy.spec.ts', + 'auction.spec.ts', 'battleSimulator.spec.ts', 'battleSimulatorRef.spec.ts', ], diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 5d3a6bd..cac0d86 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -11,6 +11,7 @@ "test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs", "test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs", "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs", "test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", 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/router/index.ts b/app/game-frontend/src/router/index.ts index 7bf3121..77b2592 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -34,6 +34,34 @@ import NationBettingView from '../views/NationBettingView.vue'; import NpcListView from '../views/NpcListView.vue'; import TrafficView from '../views/TrafficView.vue'; import { useSessionStore } from '../stores/session'; +import { trpc } from '../utils/trpc'; + +const accessPageByRouteName = { + home: 'front-info', + 'nation-info': 'nation-info', + 'nation-cities': 'nation-cities', + 'global-info': 'global-info', + 'current-city': 'current-city', + diplomacy: 'diplomacy', + 'nation-generals': 'nation-generals', + 'nation-personnel': 'nation-personnel', + 'nation-finance': 'nation-finance', + 'battle-center': 'battle-center', + board: 'board', + 'board-secret': 'board', + 'best-general': 'best-general', + 'hall-of-fame': 'hall-of-fame', + 'dynasty-list': 'dynasty', + 'dynasty-detail': 'dynasty', + yearbook: 'yearbook', + 'nation-betting': 'nation-betting', + traffic: 'traffic', + 'npc-list': 'npc-list', + 'my-page': 'my-page', + 'npc-control': 'npc-control', + tournament: 'tournament', + betting: 'betting', +} as const; const routes = [ { @@ -371,4 +399,14 @@ router.beforeEach(async (to) => { return true; }); +router.afterEach((to) => { + const session = useSessionStore(); + const routeName = typeof to.name === 'string' ? to.name : ''; + const page = accessPageByRouteName[routeName as keyof typeof accessPageByRouteName]; + if (!page || !session.hasGeneral) { + return; + } + void trpc.public.recordAccess.mutate({ page }).catch(() => undefined); +}); + export default router; 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/AuctionView.vue b/app/game-frontend/src/views/AuctionView.vue index ca80e55..7bcf928 100644 --- a/app/game-frontend/src/views/AuctionView.vue +++ b/app/game-frontend/src/views/AuctionView.vue @@ -1,8 +1,7 @@ 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(() => {
-
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} / - DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / BATTLE SIM: + DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / AUCTION: + {{ profile.runtime.auctionRunning ? 'ON' : 'OFF' }} / BATTLE SIM: {{ profile.runtime.battleSimRunning ? 'ON' : 'OFF' }} / TOURNAMENT: {{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index b39a048..bb9541a 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -19,6 +19,7 @@ type Profile = { runtime: { apiRunning: boolean; daemonRunning: boolean; + auctionRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean; }; @@ -382,6 +383,12 @@ onBeforeUnmount(() => { {{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }} +
+
Auction worker
+
+ {{ selectedProfile.runtime.auctionRunning ? 'RUNNING' : 'STOPPED' }} +
+
Battle sim worker
([ + 'warnum', + 'killnum', + 'deathnum', + 'occupied', + 'killcrew', + 'deathcrew', + 'killcrew_person', + 'deathcrew_person', +]); + +export const rankDataMetaKey = (type: RankDataType): string => + PREFIXED_RANK_DATA_TYPES.has(type) ? `rank_${type}` : type; + export const HALL_OF_FAME_TYPES = [ 'experience', 'dedication', diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index 01c8ebc..62f6814 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -53,6 +53,7 @@ export interface TurnCommandEnv { baseRice: number; generalMinimumGold?: number; generalMinimumRice?: number; + npcSeizureMessageProb?: number; maxResourceActionAmount: number; itemCatalog?: Record; generalActionModules?: Array; diff --git a/packages/logic/src/actions/turn/general/che_군량매매.ts b/packages/logic/src/actions/turn/general/che_군량매매.ts index 957d7f2..da32d96 100644 --- a/packages/logic/src/actions/turn/general/che_군량매매.ts +++ b/packages/logic/src/actions/turn/general/che_군량매매.ts @@ -16,7 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; -import { normalizeResourceActionAmount } from './resourceAmount.js'; +import { normalizeResourceActionAmount } from '../resourceAmount.js'; export interface TradeEnvironment { exchangeFee?: number; diff --git a/packages/logic/src/actions/turn/general/che_은퇴.ts b/packages/logic/src/actions/turn/general/che_은퇴.ts index 400bc3d..557e217 100644 --- a/packages/logic/src/actions/turn/general/che_은퇴.ts +++ b/packages/logic/src/actions/turn/general/che_은퇴.ts @@ -14,7 +14,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; -import { JosaUtil } from '@sammo-ts/common'; +import { JosaUtil, LEGACY_RANK_DATA_TYPES, rankDataMetaKey } from '@sammo-ts/common'; export interface RetireArgs {} @@ -22,7 +22,6 @@ const ACTION_NAME = '은퇴'; const ACTION_KEY = 'che_은퇴'; const REQ_AGE = 60; - const reqGeneralValue = (): Constraint => ({ name: 'reqGeneralValue', requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], @@ -51,46 +50,8 @@ export class ActionResolver< } nextMeta.specAge = 0; nextMeta.specAge2 = 0; - nextMeta.firenum = 0; - for (const key of [ - 'warnum', - 'killnum', - 'deathnum', - 'killcrew', - 'deathcrew', - 'ttw', - 'ttd', - 'ttl', - 'ttg', - 'ttp', - 'tlw', - 'tld', - 'tll', - 'tlg', - 'tlp', - 'tsw', - 'tsd', - 'tsl', - 'tsg', - 'tsp', - 'tiw', - 'tid', - 'til', - 'tig', - 'tip', - 'betwin', - 'betgold', - 'betwingold', - 'killcrew_person', - 'deathcrew_person', - 'occupied', - 'inherit_earned', - 'inherit_spent', - 'inherit_earned_dyn', - 'inherit_earned_act', - 'inherit_spent_dyn', - ]) { - nextMeta[`rank_${key}`] = 0; + for (const type of LEGACY_RANK_DATA_TYPES) { + nextMeta[rankDataMetaKey(type)] = 0; } const josaYi = JosaUtil.pick(general.name, '이'); diff --git a/packages/logic/src/actions/turn/general/che_증여.ts b/packages/logic/src/actions/turn/general/che_증여.ts index 2e3d6fe..c820952 100644 --- a/packages/logic/src/actions/turn/general/che_증여.ts +++ b/packages/logic/src/actions/turn/general/che_증여.ts @@ -20,7 +20,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; -import { normalizeResourceActionAmount } from './resourceAmount.js'; +import { normalizeResourceActionAmount } from '../resourceAmount.js'; const ACTION_NAME = '증여'; const ACTION_KEY = 'che_증여'; diff --git a/packages/logic/src/actions/turn/general/che_헌납.ts b/packages/logic/src/actions/turn/general/che_헌납.ts index 8f7849d..cdf59a0 100644 --- a/packages/logic/src/actions/turn/general/che_헌납.ts +++ b/packages/logic/src/actions/turn/general/che_헌납.ts @@ -21,7 +21,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; -import { normalizeResourceActionAmount } from './resourceAmount.js'; +import { normalizeResourceActionAmount } from '../resourceAmount.js'; const ACTION_NAME = '헌납'; const ACTION_KEY = 'che_헌납'; diff --git a/packages/logic/src/actions/turn/nation/che_몰수.ts b/packages/logic/src/actions/turn/nation/che_몰수.ts index 221c1bd..00d3b97 100644 --- a/packages/logic/src/actions/turn/nation/che_몰수.ts +++ b/packages/logic/src/actions/turn/nation/che_몰수.ts @@ -15,7 +15,12 @@ import type { GeneralActionOutcome, GeneralActionResolveContext, } from '@sammo-ts/logic/actions/engine.js'; -import { createLogEffect, createNationPatchEffect, createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js'; +import { + createGeneralPatchEffect, + createLogEffect, + createMessageEffect, + createNationPatchEffect, +} from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { JosaUtil } from '@sammo-ts/common'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; @@ -24,13 +29,11 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo import { clamp } from 'es-toolkit'; import { z } from 'zod'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { normalizeResourceActionAmount } from '../resourceAmount.js'; const ARGS_SCHEMA = z.object({ isGold: z.boolean(), - amount: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value), - z.number().int().positive() - ), + amount: z.number(), destGeneralID: z.number(), }); export type SeizureArgs = z.infer; @@ -39,9 +42,40 @@ export interface SeizureResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, > extends GeneralActionResolveContext { destGeneral: General; + messageTime: Date; } const ACTION_NAME = '몰수'; +const NPC_SEIZURE_MESSAGE_PROB = 0.01; +const NPC_SEIZURE_MESSAGES = [ + '몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...', + '사유재산까지 몰수해가면서 이 나라가 잘 될거라 믿습니까? 정말 이해할 수가 없군요...', + '내 돈 내놔라! 내 돈! 몰수가 웬 말이냐!', + '몰수해간 내 자금... 언젠가 몰래 다시 빼내올 것이다...', + '몰수로 인한 사기 저하는 몰수로 얻은 물자보다 더 손해란걸 모른단 말인가!', +] as const; + +type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & { + nextIntInclusive?: (maxInclusive: number) => number; +}; + +const pickLegacyNpcMessage = (rng: GeneralActionResolveContext['rng']): string => { + const inclusive = rng as InclusiveRandomGenerator; + const index = inclusive.nextIntInclusive + ? inclusive.nextIntInclusive(NPC_SEIZURE_MESSAGES.length - 1) + : rng.nextInt(0, NPC_SEIZURE_MESSAGES.length); + return NPC_SEIZURE_MESSAGES[index]!; +}; + +const resolveGeneralIcon = (general: General): string => { + const runtimePicture = (general as General & { picture?: unknown }).picture; + const rawPicture = runtimePicture ?? general.meta.picture; + const picture = + (typeof rawPicture === 'string' && rawPicture !== '') || typeof rawPicture === 'number' + ? String(rawPicture) + : 'default.jpg'; + return `/image/icons/${picture}`; +}; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -56,9 +90,13 @@ export class ActionDefinition< if (!data) { return null; } + const amount = normalizeResourceActionAmount(data.amount, this.env.maxResourceActionAmount); + if (amount === null) { + return null; + } return { ...data, - amount: clamp(data.amount, 100, this.env.maxResourceActionAmount ?? 10000), + amount, }; } @@ -147,6 +185,30 @@ export class ActionDefinition< }), ]; + if ( + destGeneral.npcState >= 2 && + context.rng.nextBool(this.env.npcSeizureMessageProb ?? NPC_SEIZURE_MESSAGE_PROB) + ) { + const target = { + generalId: destGeneral.id, + generalName: destGeneral.name, + nationId: nation.id, + nationName: nation.name, + color: nation.color, + icon: resolveGeneralIcon(destGeneral), + }; + effects.push( + createMessageEffect({ + msgType: 'public', + src: target, + dest: target, + text: pickLegacyNpcMessage(context.rng), + time: context.messageTime, + validUntil: new Date('9999-12-31T00:00:00.000Z'), + }) + ); + } + return { effects }; } } @@ -164,6 +226,7 @@ export const actionContextBuilder: ActionContextBuilder = (base, op return { ...base, destGeneral, + messageTime: base.general.turnTime, }; }; diff --git a/packages/logic/src/actions/turn/nation/che_포상.ts b/packages/logic/src/actions/turn/nation/che_포상.ts index 6ae67e0..bbce9e7 100644 --- a/packages/logic/src/actions/turn/nation/che_포상.ts +++ b/packages/logic/src/actions/turn/nation/che_포상.ts @@ -144,10 +144,10 @@ export class ActionResolver< createLogEffect( `${context.destGeneral.name}에게 ${label} ${amountText}${amountJosa} 수여했습니다.`, { - scope: LogScope.GENERAL, - category: LogCategory.ACTION, - format: LogFormat.MONTH, - } + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + } ) ); @@ -199,7 +199,7 @@ export class ActionDefinition< requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId }); } - if (ctx.destGeneralId === ctx.actorId) { + if (args.destGeneralId === ctx.actorId) { return [denyWithReason('본인입니다')]; } diff --git a/packages/logic/src/actions/turn/general/resourceAmount.ts b/packages/logic/src/actions/turn/resourceAmount.ts similarity index 100% rename from packages/logic/src/actions/turn/general/resourceAmount.ts rename to packages/logic/src/actions/turn/resourceAmount.ts diff --git a/packages/logic/src/constraints/helpers.ts b/packages/logic/src/constraints/helpers.ts index 34e9973..bc415b0 100644 --- a/packages/logic/src/constraints/helpers.ts +++ b/packages/logic/src/constraints/helpers.ts @@ -124,8 +124,11 @@ export const parsePercent = (value: string): number | null => { export type CompareOperator = '>' | '>=' | '==' | '<=' | '<' | '!=' | '===' | '!=='; export const compareValues = (target: unknown, op: CompareOperator, source: unknown): boolean => { - const lhs = target as any; - const rhs = source as any; + // The cast is type-only: JavaScript still applies its native relational + // coercion rules to the original runtime values, matching the legacy + // constraint evaluator without opting the whole comparison into `any`. + const lhs = target as number; + const rhs = source as number; switch (op) { case '<': return lhs < rhs; diff --git a/packages/logic/src/war/engine.ts b/packages/logic/src/war/engine.ts index c100d53..add7529 100644 --- a/packages/logic/src/war/engine.ts +++ b/packages/logic/src/war/engine.ts @@ -196,10 +196,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => { }; }; -const buildTraceUnitSnapshot = ( - unit: WarUnit, - defenderCity: City -): WarBattleTraceUnitSnapshot => { +const buildTraceUnitSnapshot = (unit: WarUnit, defenderCity: City): WarBattleTraceUnitSnapshot => { const common = { kind: unit instanceof WarUnitGeneral ? ('general' as const) : ('city' as const), id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : (unit as WarUnitCity).getCityId(), @@ -339,7 +336,6 @@ export const resolveWarBattle = | null = null; const getNextDefender = ( _prevDefender: WarUnit | null, @@ -359,7 +355,7 @@ export const resolveWarBattle = { expect(updatedLord.experience).toBe(700); }); - it('che_증여: 최소 보유량을 넘는 자원만 이전한다', async () => { + it('che_증여: 금은 레거시 최소 보유량 0을 적용해 요청한 금액을 이전한다', async () => { const actor = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 }); const dest = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 }); const nation = makeNation({ id: 1, name: '오', chiefGeneralId: 1, capitalCityId: 1, level: 1 }); @@ -246,8 +246,8 @@ describe('migrated general commands', () => { }, ]); - expect(world.getGeneral(actor.id)!.gold).toBe(1000); - expect(world.getGeneral(dest.id)!.gold).toBe(500); + expect(world.getGeneral(actor.id)!.gold).toBe(800); + expect(world.getGeneral(dest.id)!.gold).toBe(700); }); it('che_해산: 방랑군 해산 시 세력과 소속을 정리한다', async () => { diff --git a/packages/logic/test/scenarios/general_commands_new.test.ts b/packages/logic/test/scenarios/general_commands_new.test.ts index a103fd7..abf46e9 100644 --- a/packages/logic/test/scenarios/general_commands_new.test.ts +++ b/packages/logic/test/scenarios/general_commands_new.test.ts @@ -258,7 +258,6 @@ describe('General Commands New Scenario', () => { // 6. Retire (Needs age >= 60) // Manually set age - // Manually set age const gToRetire = { ...g1_after_resign, age: 65 }; world.snapshot.generals = world.snapshot.generals.map((g) => (g.id === 1 ? gToRetire : g)); const retireDef = retireSpec.createDefinition(systemEnv); @@ -274,7 +273,7 @@ describe('General Commands New Scenario', () => { const g1_after_retire = world.getGeneral(1)!; expect(g1_after_retire.age).toBe(20); // General::rebirth()는 앞선 명령으로 누적된 경험을 초기화하지 않고 절반으로 줄인다. - expect(g1_after_retire.experience).toBe(142); + expect(g1_after_retire.experience).toBe(Math.round(gToRetire.experience * 0.5)); }); it('should execute employ and sabotage commands', async () => { diff --git a/tools/frontend-legacy-parity/auction-ref-measure.mjs b/tools/frontend-legacy-parity/auction-ref-measure.mjs new file mode 100644 index 0000000..8114e29 --- /dev/null +++ b/tools/frontend-legacy-parity/auction-ref-measure.mjs @@ -0,0 +1,257 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const targetRoot = process.env.REF_AUCTION_URL ?? 'https://dev-sam-ref.hided.net/sam/'; +const secretRoot = process.env.REF_SECRET_ROOT; +const username = process.env.REF_USER_ID ?? 'refuser1'; +const passwordFile = process.env.REF_PASSWORD_FILE ?? 'user1_password'; +const allowGeneralCreate = process.env.REF_CREATE_GENERAL === '1'; +const outputRoot = resolve( + process.env.REF_AUCTION_ARTIFACT_DIR ?? resolve(repositoryRoot, 'test-results/auction-reference') +); + +if (!secretRoot) { + throw new Error('REF_SECRET_ROOT is required.'); +} + +const password = (await readFile(resolve(secretRoot, passwordFile), 'utf8')).trim(); +const viewports = [ + { name: 'desktop', width: 1000, height: 800 }, + { name: 'mobile', width: 500, height: 800 }, +]; + +const login = async (context) => { + const page = await context.newPage(); + await page.goto(targetRoot, { waitUntil: 'networkidle', timeout: 60_000 }); + const globalSalt = await page.locator('#global_salt').inputValue(); + const clientPasswordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const response = await context.request.post(new URL('api.php?path=Login/LoginByID', targetRoot).toString(), { + data: { username, password: clientPasswordHash }, + timeout: 60_000, + }); + const result = await response.json(); + if (!response.ok() || result.result !== true) { + throw new Error('Reference login failed.'); + } + if (allowGeneralCreate) { + const joinUrl = new URL('hwe/v_join.php', targetRoot).toString(); + await page.goto(joinUrl, { waitUntil: 'networkidle', timeout: 60_000 }); + if (page.url().includes('v_join.php')) { + const createButton = page.getByRole('button', { name: '장수 생성', exact: true }); + try { + await createButton.waitFor({ state: 'visible', timeout: 30_000 }); + } catch { + const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300); + throw new Error(`Reference general form did not render: ${page.url()} | ${pageText}`); + } + page.on('dialog', async (dialog) => dialog.accept()); + await createButton.click(); + await page.waitForURL((url) => !url.pathname.endsWith('/v_join.php'), { timeout: 60_000 }); + } + } + await page.close(); +}; + +const roundedRect = (rect) => ({ + x: Math.round(rect.x * 100) / 100, + y: Math.round(rect.y * 100) / 100, + width: Math.round(rect.width * 100) / 100, + height: Math.round(rect.height * 100) / 100, +}); + +const measurePage = async (page, type) => { + const diagnostics = []; + const onPageError = (error) => diagnostics.push(`pageerror: ${error.message}`); + const onConsole = (message) => { + if (message.type() === 'error') { + diagnostics.push(`console: ${message.text()}`); + } + }; + const onResponse = (response) => { + if (response.status() >= 400) { + diagnostics.push(`http ${response.status()}: ${response.url()}`); + } + }; + page.on('pageerror', onPageError); + page.on('console', onConsole); + page.on('response', onResponse); + const relative = type === 'unique' ? 'hwe/v_auction.php?type=unique' : 'hwe/v_auction.php'; + await page.goto(new URL(relative, targetRoot).toString(), { waitUntil: 'networkidle', timeout: 60_000 }); + try { + await page.locator('#container').waitFor({ state: 'visible', timeout: 30_000 }); + } catch { + const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300); + const scripts = await page.locator('script').evaluateAll((elements) => + elements.map((element) => ({ + src: element.getAttribute('src'), + type: element.getAttribute('type'), + length: element.textContent?.length ?? 0, + })) + ); + throw new Error( + `Reference auction did not render: ${page.url()} | ${await page.title()} | ${pageText} | ${JSON.stringify(scripts)} | ${diagnostics.slice(0, 8).join(' | ')}` + ); + } finally { + page.off('pageerror', onPageError); + page.off('console', onConsole); + page.off('response', onResponse); + } + await page.locator('button').first().waitFor({ state: 'visible' }); + await page.evaluate(() => document.fonts.ready); + + const measurement = await page.evaluate(() => { + const rect = (element) => { + if (!(element instanceof HTMLElement)) { + return null; + } + const value = element.getBoundingClientRect(); + return { x: value.x, y: value.y, width: value.width, height: value.height }; + }; + const style = (element) => { + if (!(element instanceof HTMLElement)) { + return null; + } + const value = getComputedStyle(element); + return { + color: value.color, + backgroundColor: value.backgroundColor, + backgroundImage: value.backgroundImage, + borderColor: value.borderColor, + borderWidth: value.borderWidth, + borderRadius: value.borderRadius, + fontFamily: value.fontFamily, + fontSize: value.fontSize, + fontWeight: value.fontWeight, + lineHeight: value.lineHeight, + padding: value.padding, + cursor: value.cursor, + }; + }; + const container = document.querySelector('#container'); + const topBar = container?.firstElementChild ?? null; + const topBarButtons = [...(topBar?.querySelectorAll('button') ?? [])].map((element) => ({ + text: element.textContent?.trim() ?? '', + rect: rect(element), + style: style(element), + })); + const firstButton = document.querySelector('button'); + const firstInput = [...document.querySelectorAll('input')].find( + (element) => element.getBoundingClientRect().width > 0 + ); + const firstAuctionRow = document.querySelector('.auctionItem'); + const firstAuctionRowChildren = [...(firstAuctionRow?.children ?? [])].map((element) => ({ + className: element.className, + rect: rect(element), + style: style(element), + })); + const firstSection = [...document.querySelectorAll('#container > div')].find((element) => + ['쌀 구매', '쌀 판매'].includes(element.textContent?.trim() ?? '') + ); + const directChildren = [...(container?.children ?? [])].slice(0, 12).map((element) => ({ + tag: element.tagName, + className: element.className, + text: element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80) ?? '', + rect: rect(element), + })); + return { + viewport: { width: window.innerWidth, height: window.innerHeight }, + document: { + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + }, + body: { rect: rect(document.body), style: style(document.body) }, + container: { rect: rect(container), style: style(container) }, + topBar: { rect: rect(topBar), style: style(topBar) }, + topBarButtons, + firstButton: { rect: rect(firstButton), style: style(firstButton) }, + firstInput: { rect: rect(firstInput), style: style(firstInput) }, + firstAuctionRow: { rect: rect(firstAuctionRow), style: style(firstAuctionRow) }, + firstAuctionRowChildren, + firstSection: { rect: rect(firstSection), style: style(firstSection) }, + directChildren, + }; + }); + + return { + ...measurement, + body: { + ...measurement.body, + rect: measurement.body.rect ? roundedRect(measurement.body.rect) : null, + }, + container: { + ...measurement.container, + rect: measurement.container.rect ? roundedRect(measurement.container.rect) : null, + }, + topBar: { + ...measurement.topBar, + rect: measurement.topBar.rect ? roundedRect(measurement.topBar.rect) : null, + }, + topBarButtons: measurement.topBarButtons.map((button) => ({ + ...button, + rect: button.rect ? roundedRect(button.rect) : null, + })), + firstButton: { + ...measurement.firstButton, + rect: measurement.firstButton.rect ? roundedRect(measurement.firstButton.rect) : null, + }, + firstInput: { + ...measurement.firstInput, + rect: measurement.firstInput.rect ? roundedRect(measurement.firstInput.rect) : null, + }, + firstAuctionRow: { + ...measurement.firstAuctionRow, + rect: measurement.firstAuctionRow.rect ? roundedRect(measurement.firstAuctionRow.rect) : null, + }, + firstAuctionRowChildren: measurement.firstAuctionRowChildren.map((child) => ({ + ...child, + rect: child.rect ? roundedRect(child.rect) : null, + })), + firstSection: { + ...measurement.firstSection, + rect: measurement.firstSection.rect ? roundedRect(measurement.firstSection.rect) : null, + }, + directChildren: measurement.directChildren.map((child) => ({ + ...child, + rect: child.rect ? roundedRect(child.rect) : null, + })), + }; +}; + +await mkdir(outputRoot, { recursive: true }); +const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); +const results = {}; +try { + for (const viewport of viewports) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + colorScheme: 'dark', + }); + try { + await login(context); + const page = await context.newPage(); + for (const type of ['resource', 'unique']) { + results[`${viewport.name}-${type}`] = await measurePage(page, type); + await page.screenshot({ + path: resolve(outputRoot, `${viewport.name}-${type}.png`), + fullPage: true, + }); + } + } finally { + await context.close(); + } + } +} finally { + await browser.close(); +} + +const outputPath = resolve(outputRoot, 'computed-dom.json'); +await writeFile(outputPath, `${JSON.stringify(results, null, 2)}\n`); +console.log(JSON.stringify({ outputPath, views: Object.keys(results) })); diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index d7d118f..b130574 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -13,6 +13,7 @@ export interface CanonicalTurnSnapshot { engine: CanonicalEngine; world: Record; generals: Array>; + rankData: Array>; cities: Array>; nations: Array>; diplomacy: Array>; @@ -91,6 +92,7 @@ export const projectCoreDatabaseSnapshot = (rows: { meta: unknown; }; generals: Array>; + rankData: Array>; cities: Array>; nations: Array>; diplomacy: Array>; @@ -99,6 +101,7 @@ export const projectCoreDatabaseSnapshot = (rows: { logs: Array>; }): CanonicalTurnSnapshot => { const worldMeta = asRecord(rows.world.meta); + const legacyRankTypes = new Set(LEGACY_RANK_DATA_TYPES); const generals = rows.generals.map((row) => { const meta = asRecord(row.meta); return { @@ -235,6 +238,14 @@ export const projectCoreDatabaseSnapshot = (rows: { isUnited: readNumber(worldMeta, 'isUnited', readNumber(worldMeta, 'isunited')), }, generals, + rankData: rows.rankData + .filter((row) => typeof row.type === 'string' && legacyRankTypes.has(row.type)) + .map((row) => ({ + generalId: row.generalId, + nationId: row.nationId, + type: row.type, + value: row.value, + })), cities, nations, diplomacy, @@ -248,3 +259,4 @@ export const projectCoreDatabaseSnapshot = (rows: { }, }; }; +import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; diff --git a/tools/integration-tests/src/turn-differential/compare.ts b/tools/integration-tests/src/turn-differential/compare.ts index 7d3b518..2d60c37 100644 --- a/tools/integration-tests/src/turn-differential/compare.ts +++ b/tools/integration-tests/src/turn-differential/compare.ts @@ -14,6 +14,12 @@ export interface SnapshotComparisonOptions { type FlatSnapshot = Map; const entityKey = (value: Record, index: number): string => { + if ( + (typeof value.generalId === 'number' || typeof value.generalId === 'string') && + typeof value.type === 'string' + ) { + return `${String(value.generalId)}:${value.type}`; + } for (const key of ['id', 'generalId', 'nationId', 'fromNationId']) { const candidate = value[key]; if (typeof candidate === 'number' || typeof candidate === 'string') { diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 8035dce..fb9c416 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -18,6 +18,10 @@ import type { TurnWorldSnapshot, TurnWorldState, } from '@sammo-ts/game-engine/turn/types.js'; +import { + applyPersistedRankRowsToMeta, + buildLegacyComparableRankRows, +} from '@sammo-ts/game-engine/turn/rankData.js'; import { canonicalizeTurnCommandArgs, @@ -47,6 +51,7 @@ export interface TurnCommandFixtureRequest { }; isolateWorld?: boolean; generals?: Array>; + rankData?: Array<{ generalId: number; type: string; value: number }>; nations?: Array>; cities?: Array>; troops?: Array>; @@ -295,6 +300,17 @@ const buildWorldInput = ( const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1); const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`); const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime)); + for (const general of generals) { + applyPersistedRankRowsToMeta( + general.meta, + referenceBefore.rankData + .filter((row) => readNumber(row, 'generalId') === general.id) + .map((row) => ({ + type: readString(row, 'type', ''), + value: readNumber(row, 'value'), + })) + ); + } const referenceGeneralCooldowns = Array.isArray(referenceBefore.world.generalCooldowns) ? referenceBefore.world.generalCooldowns : []; @@ -350,6 +366,7 @@ const buildWorldInput = ( baseRice: 2_000, generalMinimumGold: 0, generalMinimumRice: 500, + npcSeizureMessageProb: 0.01, maxResourceActionAmount: 10_000, maxTechLevel: 12, maxLevel: 255, @@ -523,6 +540,11 @@ const projectWorld = ( }), }, generals, + rankData: world + .listGenerals() + .filter((general) => selector.generalIds.has(general.id)) + .flatMap(buildLegacyComparableRankRows) + .map((row) => ({ ...row })), cities: world .listCities() .filter((city) => selector.cityIds.has(city.id)) diff --git a/tools/integration-tests/src/turn-differential/databaseSnapshot.ts b/tools/integration-tests/src/turn-differential/databaseSnapshot.ts index 589d10c..9d34281 100644 --- a/tools/integration-tests/src/turn-differential/databaseSnapshot.ts +++ b/tools/integration-tests/src/turn-differential/databaseSnapshot.ts @@ -11,11 +11,15 @@ export const readCoreDatabaseSnapshot = async ( try { const db = connector.prisma; const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); - const [generals, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([ + const [generals, rankData, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([ db.general.findMany({ where: { id: { in: selector.generalIds } }, orderBy: { id: 'asc' }, }), + db.rankData.findMany({ + where: { generalId: { in: selector.generalIds } }, + orderBy: [{ generalId: 'asc' }, { type: 'asc' }], + }), db.city.findMany({ where: { id: { in: selector.cityIds } }, orderBy: { id: 'asc' }, @@ -54,6 +58,7 @@ export const readCoreDatabaseSnapshot = async ( return projectCoreDatabaseSnapshot({ world, generals, + rankData, cities, nations, diplomacy, diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index 763154e..782070c 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -451,6 +451,10 @@ describe('auction integration flow', () => { const initialScore = await redis.zScore(keys.timerKey, String(auction.id)); expect(Number(initialScore)).toBe(initialCloseAt.getTime()); + await expect(hostClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow( + '자신이 연 경매에 입찰할 수 없습니다.' + ); + const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidder1.accessToken, }); diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 3ebcdf6..416a43d 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; @@ -72,6 +73,7 @@ interface FixturePatches { troops?: Array>; diplomacy?: Record>; randomFoundingCandidateCityIds?: number[]; + rankData?: Array<{ generalId: number; type: string; value: number }>; } const buildRequest = ( @@ -166,6 +168,7 @@ const buildRequest = ( { ...general(2, 2, 70, 12), ...fixturePatches.generals?.[2] }, { ...general(3, 1, 3, 1), ...fixturePatches.generals?.[3] }, ], + ...(fixturePatches.rankData ? { rankData: fixturePatches.rankData } : {}), ...(fixturePatches.troops ? { troops: fixturePatches.troops } : {}), ...(fixturePatches.randomFoundingCandidateCityIds ? { randomFoundingCandidateCityIds: fixturePatches.randomFoundingCandidateCityIds } @@ -382,6 +385,67 @@ integration('general command success matrix', () => { ); }); +integration('명장일람 rank_data command parity', () => { + it('화계 increments firenum from the same seeded value as legacy', async () => { + const request = buildRequest( + 'che_화계', + { destCityID: 70 }, + { intelligence: 100 }, + { + generals: { 2: { intelligence: 10 } }, + rankData: [{ generalId: 1, type: 'firenum', value: 17 }], + } + ); + request.setup!.world!.hiddenSeed = 'general-injury-4'; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.after.rankData).toContainEqual( + expect.objectContaining({ generalId: 1, type: 'firenum', value: 18 }) + ); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); + + it('은퇴 resets every legacy RankColumn row exactly like legacy', async () => { + const request = buildRequest( + 'che_은퇴', + undefined, + { age: 65, lastTurn: { command: '은퇴', term: 1 } }, + { + rankData: LEGACY_RANK_DATA_TYPES.map((type, index) => ({ + generalId: 1, + type, + value: index + 1, + })), + } + ); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.after.rankData.filter((row) => row.generalId === 1)).toHaveLength( + LEGACY_RANK_DATA_TYPES.length + ); + expect(reference.after.rankData.filter((row) => row.generalId === 1).every((row) => row.value === 0)).toBe( + true + ); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); +}); + type GeneralFailureCase = { action: string; args?: Record; diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index 228226e..545f654 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -11,6 +11,9 @@ const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT; const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd()); const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); +const readGold = (row: { gold?: unknown } | undefined): number => (typeof row?.gold === 'number' ? row.gold : 0); +const NPC_SEIZURE_MESSAGE_TEXT = '몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...'; + const ignoredLifecyclePaths = [ /^generalTurns/, /^nationTurns/, @@ -469,3 +472,204 @@ integration('nation command success matrix', () => { 120_000 ); }); + +const nationResourceAmountCases: Array<{ + name: string; + action: 'che_포상' | 'che_몰수'; + args: Record; + expectedAmount: number; +}> = [ + { + name: 'award rounds a half unit up', + action: 'che_포상', + args: { isGold: true, amount: 150, destGeneralID: 3 }, + expectedAmount: 200, + }, + { + name: 'award clamps below the minimum', + action: 'che_포상', + args: { isGold: true, amount: 1, destGeneralID: 3 }, + expectedAmount: 100, + }, + { + name: 'award clamps above the maximum', + action: 'che_포상', + args: { isGold: true, amount: 10_050, destGeneralID: 3 }, + expectedAmount: 10_000, + }, + { + name: 'seizure rounds a half unit up', + action: 'che_몰수', + args: { isGold: true, amount: 150, destGeneralID: 3 }, + expectedAmount: 200, + }, + { + name: 'seizure clamps below the minimum', + action: 'che_몰수', + args: { isGold: true, amount: 1, destGeneralID: 3 }, + expectedAmount: 100, + }, + { + name: 'seizure clamps above the maximum', + action: 'che_몰수', + args: { isGold: true, amount: 10_050, destGeneralID: 3 }, + expectedAmount: 10_000, + }, +]; + +integration('nation command resource amount normalization matrix', () => { + it.each(nationResourceAmountCases)( + '$name matches legacy rounding and clamp semantics', + async ({ action, args, expectedAmount }) => { + const request = buildRequest(action, args); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const referenceTargetBefore = reference.before.generals.find((entry) => entry.id === 3); + const referenceTargetAfter = reference.after.generals.find((entry) => entry.id === 3); + const coreTargetBefore = core.before.generals.find((entry) => entry.id === 3); + const coreTargetAfter = core.after.generals.find((entry) => entry.id === 3); + const referenceAmount = + action === 'che_포상' + ? readGold(referenceTargetAfter) - readGold(referenceTargetBefore) + : readGold(referenceTargetBefore) - readGold(referenceTargetAfter); + const coreAmount = + action === 'che_포상' + ? readGold(coreTargetAfter) - readGold(coreTargetBefore) + : readGold(coreTargetBefore) - readGold(coreTargetAfter); + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: action, + usedFallback: false, + }); + expect(referenceAmount).toBe(expectedAmount); + expect(coreAmount).toBe(expectedAmount); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); + +const nationResourceBoundaryCases: Array<{ + name: string; + action: 'che_포상' | 'che_몰수'; + args: Record; + fixturePatches?: FixturePatches; + completed: boolean; +}> = [ + { + name: 'award is limited to the available nation gold', + action: 'che_포상', + args: { isGold: true, amount: 10_000, destGeneralID: 3 }, + fixturePatches: { nations: { 1: { gold: 5_000 } } }, + completed: true, + }, + { + name: 'award keeps the legacy base rice reserve', + action: 'che_포상', + args: { isGold: false, amount: 10_000, destGeneralID: 3 }, + fixturePatches: { nations: { 1: { rice: 2_100 } } }, + completed: true, + }, + { + name: 'award rejects the actor as its target', + action: 'che_포상', + args: { isGold: true, amount: 100, destGeneralID: 1 }, + completed: false, + }, + { + name: 'seizure is limited to the target general gold', + action: 'che_몰수', + args: { isGold: true, amount: 1_000, destGeneralID: 3 }, + fixturePatches: { generals: { 3: { gold: 50 } } }, + completed: true, + }, + { + name: 'seizure rejects the actor as its target', + action: 'che_몰수', + args: { isGold: true, amount: 100, destGeneralID: 1 }, + completed: false, + }, +]; + +integration('nation command resource balance and target boundaries', () => { + it.each(nationResourceBoundaryCases)( + '$name matches legacy completion, RNG, and state delta', + async ({ action, args, fixturePatches, completed }) => { + const request = buildRequest(action, args, fixturePatches); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: completed ? action : '휴식', + usedFallback: !completed, + }); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); + +integration('nation seizure NPC public message parity', () => { + it('matches the legacy fixed-seed RNG and public message side effect', async () => { + const request = buildRequest( + 'che_몰수', + { isGold: true, amount: 100, destGeneralID: 3 }, + { + world: { hiddenSeed: 'seizure-message-37' }, + generals: { 3: { name: '몰수NPC', npcState: 2 } }, + } + ); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(reference.rng).toHaveLength(2); + expect(reference.rng.map((call) => call.operation)).toEqual(['nextFloat1', 'nextInt']); + expect(core.rng).toEqual(reference.rng); + const referenceMessages = reference.after.messages.slice(reference.before.messages.length); + expect(referenceMessages).toHaveLength(1); + expect(core.after.messages).toHaveLength(1); + expect(referenceMessages[0]).toMatchObject({ + mailbox: 9999, + type: 'public', + sourceId: 3, + destinationId: 9999, + payload: { + src: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' }, + dest: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' }, + text: NPC_SEIZURE_MESSAGE_TEXT, + }, + }); + expect(core.after.messages[0]).toMatchObject({ + payload: { + msgType: 'public', + src: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' }, + dest: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' }, + text: NPC_SEIZURE_MESSAGE_TEXT, + }, + }); + }, 120_000); +}); diff --git a/tools/integration-tests/test/turnSnapshotComparator.test.ts b/tools/integration-tests/test/turnSnapshotComparator.test.ts index 6579135..60973ba 100644 --- a/tools/integration-tests/test/turnSnapshotComparator.test.ts +++ b/tools/integration-tests/test/turnSnapshotComparator.test.ts @@ -15,6 +15,7 @@ const snapshot = ( engine, world: { year: 183, month: 1, tickMinutes: 10, turnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 }, generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }], + rankData: [], cities: [{ id: 1, nationId: 1, agriculture: 1000, defence: 500 }], nations: [{ id: 1, gold: 0, rice: 0 }], diplomacy: [], @@ -44,6 +45,23 @@ describe('turn snapshot differential comparator', () => { expect(compareTurnSnapshots(reference, core)).toEqual([]); }); + it('compares rank rows by general and type instead of array position', () => { + const reference = snapshot('ref', { + rankData: [ + { generalId: 2, nationId: 1, type: 'firenum', value: 3 }, + { generalId: 1, nationId: 1, type: 'warnum', value: 5 }, + ], + }); + const core = snapshot('core2026', { + rankData: [ + { generalId: 1, nationId: 1, type: 'warnum', value: 5 }, + { generalId: 2, nationId: 1, type: 'firenum', value: 3 }, + ], + }); + + expect(compareTurnSnapshots(reference, core)).toEqual([]); + }); + it('normalizes legacy ID argument spelling at the trace boundary', () => { expect( canonicalizeTurnCommandArgs({