diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 141a54c1..0cd9df52 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -44,6 +44,7 @@ export type WorldStateConfig = z.infer; export const zWorldStateMeta = z.object({ serverId: z.string().optional(), + gameIdx: z.number().int().positive().optional(), starttime: z.string().optional(), opentime: z.string().optional(), preopenAt: z.string().optional(), diff --git a/app/game-api/src/router/dynasty/index.ts b/app/game-api/src/router/dynasty/index.ts index 89cd8a4c..c7c79380 100644 --- a/app/game-api/src/router/dynasty/index.ts +++ b/app/game-api/src/router/dynasty/index.ts @@ -4,11 +4,13 @@ import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import { procedure, router } from '../../trpc.js'; +import type { LegacyEmperorRow } from '../../services/legacyArchiveStore.js'; import { findLegacyEmperor, - findLegacyEmperors, + findLegacyEmperorsByProfile, findLegacyGeneralsForServer, findLegacyNations, + isLegacyArchiveProfile, } from '../../services/legacyArchiveStore.js'; const zDynastyDetailInput = z.object({ @@ -65,7 +67,7 @@ const firstText = (...values: unknown[]): string => { return ''; }; -const legacyEmperorListEntry = (row: Awaited>[number]) => { +const legacyEmperorListEntry = (row: LegacyEmperorRow) => { const data = asRecord(row.data); return { id: Number(row.id), @@ -132,7 +134,9 @@ const formatNationLevel = (level: number | null): string => { export const dynastyRouter = router({ getList: procedure.input(zDynastyListInput).query(async ({ ctx, input }) => { if ((input?.source ?? 'current') === 'legacy') { - const rows = await findLegacyEmperors(ctx.db); + const rows = isLegacyArchiveProfile(ctx.profile.id) + ? await findLegacyEmperorsByProfile(ctx.db, ctx.profile.id) + : []; return { source: 'legacy' as const, current: null, @@ -186,7 +190,12 @@ export const dynastyRouter = router({ }), getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => { if (input.source === 'legacy') { - const archived = await findLegacyEmperor(ctx.db, input.emperorId); + const archived = isLegacyArchiveProfile(ctx.profile.id) + ? await findLegacyEmperor(ctx.db, { + id: input.emperorId, + sourceProfile: ctx.profile.id, + }) + : null; if (!archived) { throw new TRPCError({ code: 'NOT_FOUND', message: '이전 서버 왕조 정보를 찾을 수 없습니다.' }); } diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index b67d17d8..955ceb2e 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -12,7 +12,9 @@ import { isWarTraitKey, } from '@sammo-ts/logic'; import type { InheritBuffType } from '@sammo-ts/logic'; +import type { ItemSlot } from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; +import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { appendInheritanceLog, buildResetCost, @@ -38,6 +40,8 @@ const BUFF_KEYS: InheritBuffType[] = [ 'warMagicTrialProbOppose', ]; +const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item']; + const BUFF_LABELS: Record = { warAvoidRatio: '회피 확률 증가', warCriticalRatio: '필살 확률 증가', @@ -74,17 +78,18 @@ const readBuffLevel = (buff: Record, key: InheritBuffType): numb }; const loadAvailableUniqueItems = async (worldState: WorldStateRow) => { - const configuredItems = asRecord(asRecord(worldState.config).const).allItems; + const configConst = asRecord(asRecord(worldState.config).const); + const loader = new ItemLoader(); + const { allItems } = await resolveLegacyCompatibleUniqueConfig(configConst, loader); const enabledKeys: Array[0]> = []; - for (const entries of Object.values(asRecord(configuredItems))) { + for (const slot of UNIQUE_ITEM_SLOT_ORDER) { + const entries = allItems[slot] ?? {}; for (const [key, amount] of Object.entries(asRecord(entries))) { if (asNumber(amount, 0) !== 0 && isItemKey(key)) { enabledKeys.push(key); } } } - - const loader = new ItemLoader(); const items = await Promise.all( [...new Set(enabledKeys)].map(async (key) => { const item = await loader.load(key); @@ -93,10 +98,11 @@ const loadAvailableUniqueItems = async (worldState: WorldStateRow) => { name: item.name, rawName: item.rawName, info: item.info ?? '', + slot: item.slot, }; }) ); - return items.sort((left, right) => left.name.localeCompare(right.name, 'ko')); + return items; }; const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise } } }) => { diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index 2ba7ab5e..0b611078 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -53,6 +53,8 @@ export const lobbyRouter = router({ return { serverId: worldState.meta.serverId?.trim() || ctx.profile?.name || 'game', + profile: ctx.profile.id, + gameIdx: worldState.meta.gameIdx ?? 1, year: worldState.currentYear, month: worldState.currentMonth, userCnt, diff --git a/app/game-api/src/services/legacyArchiveStore.ts b/app/game-api/src/services/legacyArchiveStore.ts index 8a35346a..b65f932c 100644 --- a/app/game-api/src/services/legacyArchiveStore.ts +++ b/app/game-api/src/services/legacyArchiveStore.ts @@ -270,7 +270,26 @@ export const findLegacyEmperors = async ( `); }; -export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number): Promise => { +export const findLegacyEmperorsByProfile = async ( + db: LegacyArchiveDatabase, + sourceProfile: LegacyArchiveProfile +): Promise => + db.$queryRaw(GamePrisma.sql` + SELECT + "id", + "source_profile" AS "sourceProfile", + "legacy_id" AS "legacyId", + "server_id" AS "serverId", + "data" + FROM "legacy_archive"."emperor" + WHERE "source_profile" = ${sourceProfile} + ORDER BY "id" DESC + `); + +export const findLegacyEmperor = async ( + db: LegacyArchiveDatabase, + input: { id: number; sourceProfile: LegacyArchiveProfile } +): Promise => { const rows = await db.$queryRaw(GamePrisma.sql` SELECT "id", @@ -279,7 +298,8 @@ export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number): "server_id" AS "serverId", "data" FROM "legacy_archive"."emperor" - WHERE "id" = ${id} + WHERE "id" = ${input.id} + AND "source_profile" = ${input.sourceProfile} LIMIT 1 `); return rows[0] ?? null; diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 15ce9ce8..3958b1da 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -135,7 +135,16 @@ describe('buildTurnCommandTable', () => { 'che_정착장려', 'che_주민선정', ], - 군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'], + 군사: [ + 'che_징병', + 'che_모병', + 'che_훈련', + 'che_사기진작', + 'che_출병', + 'che_집합', + 'che_소집해제', + 'che_첩보', + ], 인사: ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'], 계략: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'], 국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'], @@ -221,6 +230,37 @@ describe('buildTurnCommandTable', () => { }); }); + it('exposes the user-only spy command with a city target when the actor can pay the Ref cost', async () => { + const general = { ...buildGeneral(), gold: 300, rice: 300 } as GeneralRow; + const table = await buildTurnCommandTable({ + worldState: buildWorldState(), + general, + city: buildCity(), + nation: buildNation(), + nationGenerals: null, + }); + + const spy = table.general + .find(({ category }) => category === '군사') + ?.values.find(({ key }) => key === 'che_첩보'); + + expect(spy).toMatchObject({ + name: '첩보', + reqArg: true, + possible: true, + status: 'available', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + }); + }); + it('uses min-condition constraints for availability', async () => { const table = await buildTurnCommandTable({ worldState: buildWorldState(), diff --git a/app/game-api/test/dynastyRouter.test.ts b/app/game-api/test/dynastyRouter.test.ts index e4cc7f25..c71327f4 100644 --- a/app/game-api/test/dynastyRouter.test.ts +++ b/app/game-api/test/dynastyRouter.test.ts @@ -120,20 +120,24 @@ const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload const buildContext = ( auth: GameSessionTokenPayload | null, - oldNations: Array> = [oldNation, deletedOldNation] + oldNations: Array> = [oldNation, deletedOldNation], + profileId = profile.id ): GameApiContext => { + const selectedProfile = { ...profile, id: profileId, name: `${profileId}:default` }; const db = { - $queryRaw: async (query: { strings?: readonly string[] }) => { + $queryRaw: async (query: { strings?: readonly string[]; values?: unknown[] }) => { const sql = query.strings?.join(' ') ?? ''; if (sql.includes('legacy_archive"."emperor')) { + if (!query.values?.includes(selectedProfile.id)) return []; + if (sql.includes('WHERE "id"') && !query.values.includes(101)) return []; return [ { id: 101n, - sourceProfile: 'hwe', + sourceProfile: selectedProfile.id, legacyId: 7, serverId: emperor.serverId, data: { - phase: '이전 훼2기', + phase: `이전 ${selectedProfile.id.toUpperCase()} 2기`, nation_count: emperor.nationCount, nation_name: emperor.nationName, nation_hist: emperor.nationHist, @@ -170,9 +174,10 @@ const buildContext = ( ]; } if (sql.includes('legacy_archive"."nation')) { + if (!query.values?.includes(selectedProfile.id)) return []; return [ { - sourceProfile: 'hwe', + sourceProfile: selectedProfile.id, legacyId: oldNation.id, serverId: oldNation.serverId, nation: oldNation.nation, @@ -182,6 +187,7 @@ const buildContext = ( ]; } if (sql.includes('legacy_archive"."general')) { + if (!query.values?.includes(selectedProfile.id)) return []; return [ { generalNo: 11, name: '유비', lastYearMonth: 21504 }, { generalNo: 12, name: '제갈량', lastYearMonth: 21504 }, @@ -217,13 +223,13 @@ const buildContext = ( db: db as unknown as DatabaseClient, turnDaemon: new InMemoryTurnDaemonTransport(), battleSim: new InMemoryBattleSimTransport(), - profile, + profile: selectedProfile, auth, uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, redis, - accessTokenStore: new RedisAccessTokenStore(redis, profile.name), + accessTokenStore: new RedisAccessTokenStore(redis, selectedProfile.name), flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', }; @@ -252,27 +258,31 @@ describe('dynasty public read model', () => { ]); }); - it('reads previous-server dynasties only when the archive source is selected', async () => { - const caller = appRouter.createCaller(buildContext(null)); - const list = await caller.dynasty.getList({ source: 'legacy' }); - expect(list).toMatchObject({ + it('scopes previous-server dynasties and detail to the request profile', async () => { + const cheCaller = appRouter.createCaller(buildContext(null)); + const cheList = await cheCaller.dynasty.getList({ source: 'legacy' }); + expect(cheList).toMatchObject({ source: 'legacy', current: null, entries: [ expect.objectContaining({ id: 101, source: 'legacy', - sourceProfile: 'hwe', - phase: '이전 훼2기', + sourceProfile: 'che', + phase: '이전 CHE 2기', }), ], }); - const detail = await caller.dynasty.getDetail({ emperorId: 101, source: 'legacy' }); - expect(detail).toMatchObject({ + const staleListInput = { source: 'legacy' as const, sourceProfile: 'hwe' as const }; + const staleList = await cheCaller.dynasty.getList(staleListInput); + expect(staleList.entries.map((entry) => entry.sourceProfile)).toEqual(['che']); + + const cheDetail = await cheCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' }); + expect(cheDetail).toMatchObject({ source: 'legacy', - sourceProfile: 'hwe', - emperor: expect.objectContaining({ id: 101, phase: '이전 훼2기', name: '촉' }), + sourceProfile: 'che', + emperor: expect.objectContaining({ id: 101, phase: '이전 CHE 2기', name: '촉' }), nations: [ expect.objectContaining({ name: '촉', @@ -283,6 +293,22 @@ describe('dynasty public read model', () => { }), ], }); + + const staleDetailInput = { emperorId: 101, source: 'legacy' as const, sourceProfile: 'hwe' as const }; + const staleDetail = await cheCaller.dynasty.getDetail(staleDetailInput); + expect(staleDetail.sourceProfile).toBe('che'); + + const hweCaller = appRouter.createCaller(buildContext(null, undefined, 'hwe')); + const hweList = await hweCaller.dynasty.getList({ source: 'legacy' }); + expect(hweList.entries).toEqual([expect.objectContaining({ sourceProfile: 'hwe', phase: '이전 HWE 2기' })]); + const hweDetail = await hweCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' }); + expect(hweDetail.sourceProfile).toBe('hwe'); + + const developmentCaller = appRouter.createCaller(buildContext(null, undefined, 'development')); + await expect(developmentCaller.dynasty.getList({ source: 'legacy' })).resolves.toMatchObject({ entries: [] }); + await expect(developmentCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); }); it('exposes the same public DTO to anonymous, general owners and admins', async () => { diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index 4235177b..72d28db3 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -97,6 +97,7 @@ const buildContext = (options: { target?: GeneralRow | null; inheritancePoint?: number; inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>; + configConst?: Record; }) => { const auth = options.auth === undefined ? buildAuth() : options.auth; const general = options.general === undefined ? buildGeneral() : options.general; @@ -113,10 +114,19 @@ const buildContext = (options: { const logCreate = vi.fn(async () => ({})); const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : [])); const inheritanceLogFindMany = vi.fn(async () => options.inheritanceLogs ?? []); + const activeWorldState = + options.configConst === undefined + ? worldState + : { + ...worldState, + config: { + const: options.configConst, + }, + }; const db = { $queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]), worldState: { - findFirst: vi.fn(async () => worldState), + findFirst: vi.fn(async () => activeWorldState), }, general: { findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => @@ -192,6 +202,48 @@ describe('inherit router actor and permission boundaries', () => { }); }); + it.each([{}, { allItems: '{}' }])( + 'restores selectable Ref default uniques for a legacy scenario config: %j', + async (configConst) => { + const fixture = buildContext({ configConst }); + const status = await appRouter.createCaller(fixture.context).inherit.getStatus(); + + expect(status.availableUnique.length).toBeGreaterThan(80); + expect(status.availableUnique).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }), + expect.objectContaining({ key: 'che_서적_07_논어', rawName: '논어' }), + ]) + ); + } + ); + + it('orders unique auction candidates by Ref slot order and preserves order within each slot', async () => { + const fixture = buildContext({ + configConst: { + allItems: { + item: { che_보물_도기: 1 }, + book: { che_서적_07_논어: 1 }, + weapon: { che_무기_12_칠성검: 1 }, + horse: { + che_명마_07_백마: 1, + che_명마_07_기주마: 1, + }, + }, + }, + }); + + const status = await appRouter.createCaller(fixture.context).inherit.getStatus(); + + expect(status.availableUnique.map(({ key, slot }) => ({ key, slot }))).toEqual([ + { key: 'che_명마_07_백마', slot: 'horse' }, + { key: 'che_명마_07_기주마', slot: 'horse' }, + { key: 'che_무기_12_칠성검', slot: 'weapon' }, + { key: 'che_서적_07_논어', slot: 'book' }, + { key: 'che_보물_도기', slot: 'item' }, + ]); + }); + it('loads the first inheritance-log page without an out-of-range integer cursor', async () => { const createdAt = new Date('2026-07-26T00:00:00Z'); const fixture = buildContext({ diff --git a/app/game-api/test/lobbyRouter.test.ts b/app/game-api/test/lobbyRouter.test.ts index eb411eb6..144570c8 100644 --- a/app/game-api/test/lobbyRouter.test.ts +++ b/app/game-api/test/lobbyRouter.test.ts @@ -15,6 +15,7 @@ const buildContext = ( ): GameApiContext => ({ auth: null, + profile: { id: 'che', scenario: 'default', name: 'che:default' }, db: { worldState: { findFirst: vi.fn(async () => ({ @@ -75,6 +76,7 @@ describe('lobby season state', () => { buildContext( { serverId: 'che_260819_season', + gameIdx: 101, preopenAt: '2026-08-19 22:00:00', opentime: '2026-08-19 23:00:00', scenarioMeta: { title: '【가상모드27-b】 아시아 명장전(비급)' }, @@ -103,6 +105,8 @@ describe('lobby season state', () => { expect(result).toMatchObject({ serverId: 'che_260819_season', + profile: 'che', + gameIdx: 101, preopenAt: '2026-08-19 22:00:00', opentime: '2026-08-19 23:00:00', scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)', diff --git a/app/game-engine/src/auction/bidder.ts b/app/game-engine/src/auction/bidder.ts index 1372c80b..4f0ac6a0 100644 --- a/app/game-engine/src/auction/bidder.ts +++ b/app/game-engine/src/auction/bidder.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; +import { isItemKey, ItemLoader } from '@sammo-ts/logic'; import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; @@ -23,6 +24,7 @@ const MIN_EXTENSION_MINUTES_PER_BID = 1; interface AuctionRow { id: number; type: AuctionType; + targetCode: string | null; hostGeneralId: number; detail: unknown; status: AuctionStatus; @@ -99,6 +101,7 @@ const loadAuction = async (prisma: QueryClient, auctionId: number): Promise => { @@ -290,6 +294,74 @@ export const createAuctionBidder = async (options: { reason: '장수 정보를 찾을 수 없습니다.', }; } + if (auction.type === 'UNIQUE_ITEM') { + const itemKey = auction.targetCode; + if (!itemKey || !isItemKey(itemKey)) { + return { + type: 'auctionBid', + ok: false, + auctionId: command.auctionId, + reason: '아이템이 올바르지 않습니다.', + }; + } + const item = await itemLoader.load(itemKey).catch(() => null); + if (!item || item.buyable) { + return { + type: 'auctionBid', + ok: false, + auctionId: command.auctionId, + reason: item ? '구매할 수 있는 아이템입니다.' : '아이템 정보를 불러올 수 없습니다.', + }; + } + + const currentSlotItem = general.role.items[item.slot]; + if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) { + const currentItem = await itemLoader.load(currentSlotItem).catch(() => null); + if (currentItem && !currentItem.buyable) { + return { + type: 'auctionBid', + ok: false, + auctionId: command.auctionId, + reason: + currentSlotItem === itemKey + ? '이미 그 유니크를 가지고 있습니다.' + : '이미 다른 유니크를 가지고 있습니다.', + }; + } + } + + const otherHighestBids = await db.$queryRaw>( + GamePrisma.sql` + SELECT candidate.id as "auctionId", candidate.target_code as "targetCode" + FROM auction candidate + INNER JOIN LATERAL ( + SELECT bid.general_id + FROM auction_bid bid + WHERE bid.auction_id = candidate.id + ORDER BY bid.amount DESC, bid.id ASC + LIMIT 1 + ) highest ON true + WHERE candidate.type = 'UNIQUE_ITEM' + AND candidate.status IN ('OPEN', 'FINALIZING') + AND candidate.id <> ${auction.id} + AND highest.general_id = ${command.generalId} + ` + ); + for (const other of otherHighestBids) { + if (!other.targetCode || !isItemKey(other.targetCode)) { + continue; + } + const otherItem = await itemLoader.load(other.targetCode).catch(() => null); + if (otherItem?.slot === item.slot) { + return { + type: 'auctionBid', + ok: false, + auctionId: command.auctionId, + reason: '1순위 입찰자인 경매중에 같은 부위가 있습니다.', + }; + } + } + } if (auction.type !== 'UNIQUE_ITEM' && auction.hostGeneralId === general.id) { return { type: 'auctionBid', diff --git a/app/game-engine/src/auction/finalizer.ts b/app/game-engine/src/auction/finalizer.ts index 04cde229..1e6d9bea 100644 --- a/app/game-engine/src/auction/finalizer.ts +++ b/app/game-engine/src/auction/finalizer.ts @@ -1,5 +1,6 @@ import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; -import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey, resolveUniqueConfig } from '@sammo-ts/logic'; +import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic'; +import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js'; import { asRecord, JosaUtil } from '@sammo-ts/common'; @@ -18,6 +19,8 @@ type AuctionStatus = 'OPEN' | 'FINALIZING' | 'FINISHED' | 'CANCELED'; const COEFF_EXTENSION_MINUTES_PER_BID = 1 / 6; const MIN_EXTENSION_MINUTES_PER_BID = 1; const MIN_EXTENSION_MINUTES_LIMIT_BY_BID = 5; +const COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_COUNT = 24; +const MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY = 5; interface AuctionRow { id: number; @@ -363,7 +366,10 @@ export const createAuctionFinalizer = async (options: { } const state = world.getState(); - const config = resolveUniqueConfig(asRecord(world.getScenarioConfig().const)); + const config = await resolveLegacyCompatibleUniqueConfig( + asRecord(world.getScenarioConfig().const), + itemLoader + ); const scenarioMeta = asRecord(state.meta.scenarioMeta); const startYear = typeof scenarioMeta.startYear === 'number' && Number.isFinite(scenarioMeta.startYear) @@ -392,7 +398,11 @@ export const createAuctionFinalizer = async (options: { const turnMinutes = await resolveTurnMinutes(db); const nextCloseAt = new Date( auction.closeAt.getTime() + - Math.max(MIN_EXTENSION_MINUTES_LIMIT_BY_BID, turnMinutes * 0.5) * 60_000 + Math.max( + MIN_EXTENSION_MINUTES_BY_EXTENSION_QUERY, + turnMinutes * COEFF_EXTENSION_MINUTES_LIMIT_UNIQUE_COUNT + ) * + 60_000 ); const nextLatestBidCloseAt = new Date( nextCloseAt.getTime() + diff --git a/app/game-engine/src/auction/opener.ts b/app/game-engine/src/auction/opener.ts index 7c47bfd7..e83c43f6 100644 --- a/app/game-engine/src/auction/opener.ts +++ b/app/game-engine/src/auction/opener.ts @@ -2,14 +2,8 @@ import { randomUUID } from 'node:crypto'; import { asRecord, JosaUtil } from '@sammo-ts/common'; import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra'; -import { - ActionLogger, - ItemLoader, - LogFormat, - buildAuctionAlias, - isItemKey, - resolveUniqueConfig, -} from '@sammo-ts/logic'; +import { ActionLogger, ItemLoader, LogFormat, buildAuctionAlias, isItemKey } from '@sammo-ts/logic'; +import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import type { TurnDaemonCommand, TurnDaemonCommandResult } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js'; @@ -147,7 +141,8 @@ const openUniqueAuction = async ( return fail(`최소 경매 금액은 ${minimumPoint}입니다.`); } - const item = await new ItemLoader().load(itemKey).catch(() => null); + const itemLoader = new ItemLoader(); + const item = await itemLoader.load(itemKey).catch(() => null); if (!item) { return fail('아이템 정보를 불러올 수 없습니다.'); } @@ -156,7 +151,7 @@ const openUniqueAuction = async ( } const currentSlotItem = general.role.items[item.slot]; if (currentSlotItem && currentSlotItem !== 'None' && isItemKey(currentSlotItem)) { - const currentItem = await new ItemLoader().load(currentSlotItem).catch(() => null); + const currentItem = await itemLoader.load(currentSlotItem).catch(() => null); if (currentItem && !currentItem.buyable) { return fail('이미 가진 아이템이 있습니다.'); } @@ -189,7 +184,7 @@ const openUniqueAuction = async ( return fail('아직 경매가 끝나지 않았습니다.'); } - const uniqueConfig = resolveUniqueConfig(configConst); + const uniqueConfig = await resolveLegacyCompatibleUniqueConfig(configConst, itemLoader); const configuredAmount = uniqueConfig.allItems[item.slot]?.[itemKey] ?? 0; const occupiedAmount = world .listGenerals() diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index a1d1c96f..3a8b17b5 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -323,9 +323,6 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom options: install.autorunUser.options, }; } - const archivedWorldMeta = { ...worldMeta }; - delete archivedWorldMeta.hiddenSeed; - await connector.connect(); try { const result: ScenarioSeedResult = { seed, warnings, applied: true }; @@ -383,6 +380,20 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom await prisma.worldState.deleteMany(); } + const serverId = typeof worldMeta.serverId === 'string' ? worldMeta.serverId : undefined; + const completedGameCount = await prisma.gameHistory.count({ + where: { + status: 'COMPLETED', + ...(serverId ? { serverId: { not: serverId } } : {}), + }, + }); + // Ref fixes server_cnt once during ResetHelper initialization. Keep the + // frequently rendered game index in the same persisted read model and + // exclude abandoned or unfinished rows from the official sequence. + worldMeta.gameIdx = completedGameCount + 1; + const archivedWorldMeta = { ...worldMeta }; + delete archivedWorldMeta.hiddenSeed; + await prisma.worldState.create({ data: { scenarioCode: String(options.scenarioId), diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 75a443f3..1fa3aeba 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -814,7 +814,11 @@ export class InMemoryTurnWorld { }; } - pushLog(entry: LogEntryDraft): void { + pushLog(entry: LogEntryDraft, occurredAt?: Date): void { + if (occurredAt && !entry.occurredAt) { + this.logs.push({ ...entry, occurredAt: new Date(occurredAt.getTime()) }); + return; + } this.logs.push(entry); } @@ -1382,7 +1386,12 @@ export class InMemoryTurnWorld { this.dirtyNationIds.add(result.nation.id); } if (result.logs && result.logs.length > 0) { - this.logs.push(...result.logs); + // Ref command logs use the executing general's pre-advance turntime. + // Preserve that per-entry occurrence time instead of replacing every + // log in the transaction with the shared completion cursor at flush. + for (const log of result.logs) { + this.pushLog(log, currentGeneral.turnTime); + } } if (result.messages && result.messages.length > 0) { this.messages.push(...result.messages); diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index aca2f06b..8043a2a6 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -156,15 +156,15 @@ export const applyLegacyGeneralProgression = ( meta.explevel = expLevel; if (expLevel !== previousExpLevel && actionResolvedExpLevel !== expLevel) { const josaRo = JosaUtil.pick(String(expLevel), '로'); - logs.push({ - scope: LogScope.GENERAL, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, - text: + logs.push( + createGeneralActionLog( + general.id, expLevel > previousExpLevel ? `Lv ${expLevel}${josaRo} 레벨업!` : `Lv ${expLevel}${josaRo} 레벨다운!`, - }); + { format: LogFormat.PLAIN } + ) + ); } } if (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) { @@ -176,15 +176,15 @@ export const applyLegacyGeneralProgression = ( const billText = getBillByLevel(dedicationLevel).toLocaleString('en-US'); const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로'); const josaRoBill = JosaUtil.pick(billText, '로'); - logs.push({ - scope: LogScope.GENERAL, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, - text: + logs.push( + createGeneralActionLog( + general.id, dedicationLevel > previousDedicationLevel ? `${dedicationLevelText}${josaRoDedication} 승급하여 봉록이 ${billText}${josaRoBill} 상승했습니다!` : `${dedicationLevelText}${josaRoDedication} 강등되어 봉록이 ${billText}${josaRoBill} 하락했습니다!`, - }); + { format: LogFormat.PLAIN } + ) + ); } } @@ -715,12 +715,27 @@ const buildConstraintContext = ( mode: 'full', }); -const createActionLog = (message: string, meta?: Record): LogEntryDraft => ({ +/** + * Ref ActionLogger is constructed with a general ID, so every personal action + * log carries its owner before it reaches persistence. Keep that ownership + * explicit here: finalizeLogEntry intentionally rejects ownerless GENERAL logs. + */ +interface GeneralActionLogOptions { + format?: LogFormat; + meta?: Record; +} + +const createGeneralActionLog = ( + generalId: number, + message: string, + options: GeneralActionLogOptions = {} +): LogEntryDraft => ({ scope: LogScope.GENERAL, category: LogCategory.ACTION, - format: LogFormat.MONTH, + generalId, + format: options.format ?? LogFormat.MONTH, text: message, - meta, + ...(options.meta ? { meta: options.meta } : {}), }); const resolveDefinition = ( @@ -936,7 +951,7 @@ export const createReservedTurnHandler = async (options: { actionKey = definition.key; usedFallback = true; blockedReason = failureText; - logs.push(createActionLog(failureText)); + logs.push(createGeneralActionLog(currentGeneral.id, failureText)); } const actionConstraintEnv = { @@ -972,7 +987,7 @@ export const createReservedTurnHandler = async (options: { const failureText = failedDefinition.formatConstraintFailure?.(reason, constraintCtx, failedActionArgs, view) ?? `${reason} ${failedDefinition.name} 실패.`; - logs.push(createActionLog(failureText, meta)); + logs.push(createGeneralActionLog(currentGeneral.id, failureText, meta ? { meta } : {})); } if (!usedFallback && (kind === 'general' || currentNation)) { const currentYearMonth = joinYearMonth(context.world.currentYear, context.world.currentMonth); @@ -987,7 +1002,7 @@ export const createReservedTurnHandler = async (options: { actionKey = definition.key; usedFallback = true; blockedReason = `${remainTurn}턴 더 기다려야 합니다`; - logs.push(createActionLog(blockedReason)); + logs.push(createGeneralActionLog(currentGeneral.id, blockedReason)); } } @@ -1068,7 +1083,7 @@ export const createReservedTurnHandler = async (options: { actionKey = definition.key; usedFallback = true; blockedReason = '예약된 명령을 실행하지 못했습니다.'; - logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.')); + logs.push(createGeneralActionLog(currentGeneral.id, '예약된 명령을 실행하지 못했습니다.')); actionRng = sharedActionRng ?? buildRng(actionKey); baseContext = { general: currentGeneral, @@ -1151,7 +1166,7 @@ export const createReservedTurnHandler = async (options: { const progressText = executionDefinition.getProgressText?.(actionContext, actionArgs, nextTerm, termMax) ?? `${definition.name} 수행중... (${nextTerm}/${termMax})`; - logs.push(createActionLog(progressText)); + logs.push(createGeneralActionLog(currentGeneral.id, progressText)); return { actionKey, usedFallback, completed: false, blockedReason }; } } @@ -1576,7 +1591,7 @@ export const createReservedTurnHandler = async (options: { }, rng: preprocessRng, log: { - push: (message) => logs.push(createActionLog(message)), + push: (message) => logs.push(createGeneralActionLog(currentGeneral.id, message)), }, }); preTurnPipeline.getPreTurnExecuteTriggerList(preTurnContext).fire(preTurnContext, baseConstraintEnv); @@ -1602,7 +1617,12 @@ export const createReservedTurnHandler = async (options: { } currentGeneral.crew = 0; currentGeneral.rice = 0; - logs.push(createActionLog('군량이 모자라 병사들이 소집해제되었습니다!')); + logs.push( + createGeneralActionLog( + currentGeneral.id, + '군량이 모자라 병사들이 소집해제되었습니다!' + ) + ); preTurnContext.skill.activate('pre.소집해제'); } preTurnContext.skill.activate('pre.병력군량소모'); @@ -1625,7 +1645,8 @@ export const createReservedTurnHandler = async (options: { if (isBlocked) { currentGeneral.meta.killturn = Math.max(0, currentGeneral.meta.killturn - 1); logs.push( - createActionLog( + createGeneralActionLog( + currentGeneral.id, blockCode === 2 ? '현재 멀티, 또는 비매너로 인한블럭 대상자입니다.' : '현재 악성유저로 분류되어 블럭 대상자입니다.' @@ -1981,7 +2002,8 @@ export const createReservedTurnHandler = async (options: { ? currentGeneral.meta.owner_name : currentGeneral.userId; logs.push( - createActionLog( + createGeneralActionLog( + currentGeneral.id, `${ownerName ?? '사용자'}이 ${currentGeneral.name}의 육체에서 유체이탈합니다!` ) ); @@ -2060,7 +2082,8 @@ export const createReservedTurnHandler = async (options: { chiefGeneralId: successor.id, }; logs.push( - createActionLog( + createGeneralActionLog( + currentGeneral.id, `${successor.name}이 ${currentNation.name}의 유지를 이어 받았습니다` ) ); @@ -2093,7 +2116,12 @@ export const createReservedTurnHandler = async (options: { if (!deleteGeneral && currentGeneral.age >= retirementYear && currentGeneral.npcState === 0) { currentGeneral = resetRetiredGeneral(currentGeneral); lifecycleOutcome = 'retired'; - logs.push(createActionLog('나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.')); + logs.push( + createGeneralActionLog( + currentGeneral.id, + '나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.' + ) + ); } currentGeneral = { @@ -2245,10 +2273,7 @@ export const createImmediateGeneralActionExecutor = async (options: { definition.formatConstraintFailure?.(reason, constraintCtx, args, view) ?? `${reason} ${definition.name} 실패.`; if (input.actionKey === 'che_접경귀환' || input.actionKey === 'che_등용수락') { - options.world.pushLog({ - ...createActionLog(failureText), - generalId: general.id, - }); + options.world.pushLog(createGeneralActionLog(general.id, failureText), general.turnTime); } return { ok: false, reason: failureText }; } @@ -2325,7 +2350,7 @@ export const createImmediateGeneralActionExecutor = async (options: { if (input.actionKey === 'che_접경귀환' && (resolution.general as TurnGeneral).cityId === general.cityId) { for (const log of resolution.logs) { - options.world.pushLog(log); + options.world.pushLog(log, general.turnTime); } return { ok: false, reason: '가까운 아국 도시가 없습니다.' }; } @@ -2409,7 +2434,7 @@ export const createImmediateGeneralActionExecutor = async (options: { options.world.removeTroop(troopId); } for (const log of [...resolution.logs, ...progressionLogs]) { - options.world.pushLog(log); + options.world.pushLog(log, general.turnTime); } options.world.updateGeneral(input.generalId, nextGeneral); return { ok: true }; diff --git a/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts b/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts index 43eed0c0..73abf04c 100644 --- a/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts +++ b/app/game-engine/test/generalAccessScoreResetPersistence.integration.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; @@ -15,6 +16,7 @@ integration('general access score reset persistence', () => { let closeDb: (() => Promise) | undefined; const cleanup = async () => { + await db.logEntry.deleteMany({ where: { generalId } }); await db.generalAccessLog.deleteMany({ where: { generalId } }); await db.general.deleteMany({ where: { id: generalId } }); await db.worldState.deleteMany({ where: { scenarioCode } }); @@ -33,8 +35,9 @@ integration('general access score reset persistence', () => { await closeDb?.(); }); - it('commits the own-turn reset marker in the same world flush', async () => { + it('commits the own-turn reset marker and per-entry log occurrence time in the same world flush', async () => { const turnTime = new Date('2026-08-15T00:10:00.000Z'); + const occurredAt = new Date('2026-08-15T00:07:43.000Z'); await db.general.create({ data: { id: generalId, @@ -95,6 +98,13 @@ integration('general access score reset persistence', () => { { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } } ); world.markGeneralAccessScoreReset(generalId); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + text: '●1월:아무것도 실행하지 않았습니다.', + generalId, + occurredAt, + }); const hooks = await createDatabaseTurnHooks(databaseUrl!, world); try { @@ -113,6 +123,12 @@ integration('general access score reset persistence', () => { refreshScoreTotal: 999, }); expect(world.peekDirtyState().accessScoreResetGeneralIds).toEqual([]); + expect( + await db.logEntry.findFirstOrThrow({ + where: { generalId, category: LogCategory.ACTION }, + select: { createdAt: true }, + }) + ).toEqual({ createdAt: occurredAt }); } finally { await hooks.close(); } diff --git a/app/game-engine/test/generalTurnLifecycle.test.ts b/app/game-engine/test/generalTurnLifecycle.test.ts index 15b4389c..05f6b330 100644 --- a/app/game-engine/test/generalTurnLifecycle.test.ts +++ b/app/game-engine/test/generalTurnLifecycle.test.ts @@ -159,6 +159,25 @@ const makeState = (meta: Record = {}): TurnWorldState => ({ }); describe('legacy general turn lifecycle', () => { + it('timestamps action logs with the executing general turn instead of the shared flush cursor', async () => { + const flushCursor = new Date('0200-01-01T00:35:00.000Z'); + const generalTurnTime = new Date('0200-01-01T00:37:43.000Z'); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([makeGeneral({ turnTime: generalTurnTime })]), + state: { ...makeState(), lastTurnTime: flushCursor }, + schedule, + map, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: '휴식', args: {} }; + + await harness.runOneTick(); + + const actionLog = harness.world + .peekDirtyState() + .logs.find((log) => log.text.includes('아무것도 실행하지 않았습니다.')); + expect(actionLog?.occurredAt).toEqual(generalTurnTime); + }); + it('emits legacy plain logs when command gains cross experience and dedication levels', async () => { const harness = await createTurnTestHarness({ snapshot: makeSnapshot([ diff --git a/app/game-engine/test/nationTurnCompatibility.test.ts b/app/game-engine/test/nationTurnCompatibility.test.ts index 9cdb1163..e085f38e 100644 --- a/app/game-engine/test/nationTurnCompatibility.test.ts +++ b/app/game-engine/test/nationTurnCompatibility.test.ts @@ -224,4 +224,31 @@ describe('레거시 사령부 턴 실행 호환성', () => { }, ]); }); + + it('첩보 도시는 실행 월부터 세 달 보이고 각 월 시작에 감소한 뒤 만료된다', async () => { + const nation = { + id: 1, + meta: { + rate: 20, + spy: { 2: 3 }, + }, + }; + const handler = createNationTurnMonthlyHandler({ + getWorld: () => + ({ + listNations: () => [nation], + updateNation: (_id: number, patch: { meta?: typeof nation.meta }) => { + if (patch.meta) nation.meta = patch.meta; + }, + }) as never, + }); + + expect(nation.meta.spy).toEqual({ 2: 3 }); + await handler.beforeMonthChanged?.({} as never); + expect(nation.meta.spy).toEqual({ 2: 2 }); + await handler.beforeMonthChanged?.({} as never); + expect(nation.meta.spy).toEqual({ 2: 1 }); + await handler.beforeMonthChanged?.({} as never); + expect(nation.meta.spy).toEqual({}); + }); }); diff --git a/app/game-engine/test/reservedTurnExecution.test.ts b/app/game-engine/test/reservedTurnExecution.test.ts index fff9e833..b20be1fc 100644 --- a/app/game-engine/test/reservedTurnExecution.test.ts +++ b/app/game-engine/test/reservedTurnExecution.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { TurnSchedule } from '@sammo-ts/logic'; +import { finalizeLogEntry, type TurnSchedule } from '@sammo-ts/logic'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; @@ -41,8 +41,9 @@ const mockDate = new Date('0189-01-01T00:00:00Z'); // We need a mock Prisma client that satisfies the shape required by InMemoryReservedTurnStore // It expects { generalTurn: { findMany, deleteMany, createMany }, nationTurn: { ... } } -const createMockPrisma = (initialGeneralRows: any[] = []) => { +const createMockPrisma = (initialGeneralRows: any[] = [], initialNationRows: any[] = []) => { let generalRows = [...initialGeneralRows]; + let nationRows = [...initialNationRows]; return { generalTurn: { findMany: vi.fn(async ({ where } = {}) => { @@ -67,9 +68,28 @@ const createMockPrisma = (initialGeneralRows: any[] = []) => { }), }, nationTurn: { - findMany: vi.fn(async () => []), - deleteMany: vi.fn(async () => ({ count: 0 })), - createMany: vi.fn(async () => ({ count: 0 })), + findMany: vi.fn(async ({ where } = {}) => { + if (where?.nationId && where?.officerLevel) { + return nationRows + .filter((row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel) + .sort((left, right) => left.turnIdx - right.turnIdx); + } + return nationRows; + }), + deleteMany: vi.fn(async ({ where } = {}) => { + if (where?.nationId && where?.officerLevel) { + nationRows = nationRows.filter( + (row) => row.nationId !== where.nationId || row.officerLevel !== where.officerLevel + ); + } + return { count: 0 }; + }), + createMany: vi.fn(async ({ data }) => { + if (Array.isArray(data)) { + nationRows.push(...data); + } + return { count: data.length }; + }), }, }; }; @@ -423,8 +443,17 @@ describe('Reserved Turn Execution Integration', () => { }; const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 'bad' } }]; + const invalidNationRows = [ + { + nationId: 1, + officerLevel: 5, + turnIdx: 0, + actionCode: 'che_천도', + arg: { destCityId: 'bad' }, + }, + ]; - const mockPrisma = createMockPrisma(invalidRows); + const mockPrisma = createMockPrisma(invalidRows, invalidNationRows); const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, { maxGeneralTurns: 10, maxNationTurns: 10, @@ -456,7 +485,26 @@ describe('Reserved Turn Execution Integration', () => { const dirty = world.consumeDirtyState(); expect(world.getGeneralById(1)!.cityId).toBe(1); - expect(dirty.logs.some((log) => log.text.includes('인자가 올바르지 않습니다. 이동 실패.'))).toBe(true); + expect(dirty.logs.find((log) => log.text.includes('인자가 올바르지 않습니다. 천도 실패.'))).toMatchObject({ + scope: 'GENERAL', + category: 'ACTION', + generalId: 1, + }); + expect(dirty.logs.find((log) => log.text.includes('인자가 올바르지 않습니다. 이동 실패.'))).toMatchObject({ + scope: 'GENERAL', + category: 'ACTION', + generalId: 1, + }); + const personalActionLogs = dirty.logs.filter( + (log) => log.scope === 'GENERAL' && log.category === 'ACTION' + ); + expect(personalActionLogs.length).toBeGreaterThan(0); + expect(personalActionLogs.every((log) => log.generalId === 1)).toBe(true); + expect( + personalActionLogs.map((log) => + finalizeLogEntry(log, { year: invalidState.currentYear, month: invalidState.currentMonth }) + ) + ).not.toContain(null); expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(true); }); @@ -620,6 +668,7 @@ describe('Reserved Turn Execution Integration', () => { const denyLog = dirty.logs.find((log) => log.text.includes('같은 도시입니다.')); expect(denyLog?.text).toContain('이동 실패.'); expect(denyLog?.meta?.constraintName).toBe('notSameDestCity'); + expect(denyLog?.generalId).toBe(1); expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(true); }); diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index d516c825..c6d587c5 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -128,6 +128,58 @@ describeDb('scenario database seed', () => { } }); + test('persists the next official game index without counting cancelled or unfinished games', async () => { + const marker = `scenario-seeder-game-index-${Date.now()}`; + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const completedBefore = await connector.prisma.gameHistory.count({ where: { status: 'COMPLETED' } }); + await connector.prisma.gameHistory.createMany({ + data: [ + { + serverId: `${marker}-completed`, + date: new Date('2026-08-01T00:00:00.000Z'), + season: 1, + scenario: 1010, + scenarioName: '정상 종료 fixture', + status: 'COMPLETED', + }, + { + serverId: `${marker}-abandoned`, + date: new Date('2026-08-02T00:00:00.000Z'), + season: 1, + scenario: 1010, + scenarioName: '취소 fixture', + status: 'ABANDONED', + }, + { + serverId: `${marker}-open`, + date: new Date('2026-08-03T00:00:00.000Z'), + season: 1, + scenario: 1010, + scenarioName: '미완료 fixture', + status: 'OPEN', + }, + ], + }); + + await seedScenarioToDatabase({ + scenarioId: 1010, + databaseUrl, + installOptions: { serverId: marker }, + }); + + const worldState = await connector.prisma.worldState.findFirstOrThrow(); + expect(worldState.meta).toMatchObject({ gameIdx: completedBefore + 2 }); + await expect( + connector.prisma.gameHistory.findUniqueOrThrow({ where: { serverId: marker } }) + ).resolves.toMatchObject({ status: 'OPEN' }); + } finally { + await connector.prisma.gameHistory.deleteMany({ where: { serverId: { startsWith: marker } } }); + await connector.disconnect(); + } + }); + test('writes scenario data into tables', async () => { const { seed } = await seedScenarioToDatabase({ scenarioId, diff --git a/app/game-engine/test/turnFailureLogPersistence.integration.test.ts b/app/game-engine/test/turnFailureLogPersistence.integration.test.ts new file mode 100644 index 00000000..6f3bada5 --- /dev/null +++ b/app/game-engine/test/turnFailureLogPersistence.integration.test.ts @@ -0,0 +1,137 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient, type InputJsonValue } from '@sammo-ts/infra'; +import { LogCategory, LogFormat, LogScope, type TurnSchedule } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const worldId = 2_146_200_820; +const generalId = 2_146_200_821; +const turnTime = new Date('0190-01-01T00:00:00.000Z'); +const turnRunResult = { + lastTurnTime: turnTime.toISOString(), + processedGenerals: 1, + processedTurns: 1, + durationMs: 0, + partial: false, +} as const; + +const schedule: TurnSchedule = { + entries: [{ startMinute: 0, tickMinutes: 10 }], +}; + +const state: TurnWorldState = { + id: worldId, + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: turnTime, + meta: {}, +}; + +const snapshot: TurnWorldSnapshot = { + generals: [], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'turn-failure-log-persistence', + name: '턴 실패 로그 영속화', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'che', unitSet: 'che' }, + }, + scenarioMeta: { + title: '턴 실패 로그 영속화', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, +}; + +integration('turn failure personal-record persistence', () => { + let db: GamePrismaClient; + let disconnect: (() => Promise) | undefined; + let databaseHooks: DatabaseTurnHooks | undefined; + + const cleanup = async () => { + await db.logEntry.deleteMany({ where: { generalId } }); + await db.worldState.deleteMany({ where: { id: worldId } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + disconnect = () => connector.disconnect(); + await cleanup(); + }); + + afterAll(async () => { + await databaseHooks?.close(); + await cleanup(); + await disconnect?.(); + }); + + it('stores personal and nation-turn failure reasons under the acting general', async () => { + await db.worldState.create({ + data: { + id: worldId, + scenarioCode: 'turn-failure-log-persistence', + currentYear: state.currentYear, + currentMonth: state.currentMonth, + tickSeconds: state.tickSeconds, + config: snapshot.scenarioConfig as unknown as InputJsonValue, + meta: {}, + }, + }); + + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId, + format: LogFormat.MONTH, + text: '대상 도시가 아국이 아닙니다. 발령 실패.', + }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId, + format: LogFormat.MONTH, + text: '같은 도시입니다. 이동 실패.', + }); + + databaseHooks = await createDatabaseTurnHooks(databaseUrl!, world); + await databaseHooks.hooks.flushChanges?.(turnRunResult); + + const records = await db.logEntry.findMany({ + where: { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId, + }, + orderBy: { id: 'asc' }, + select: { generalId: true, text: true }, + }); + + expect(records).toEqual([ + { generalId, text: '●1월:대상 도시가 아국이 아닙니다. 발령 실패.' }, + { generalId, text: '●1월:같은 도시입니다. 이동 실패.' }, + ]); + }); +}); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 22248b40..c1d8afa2 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -416,6 +416,22 @@ const commandTable = { }, ], }, + { + key: 'che_첩보', + name: '첩보', + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + }, ], }, ], @@ -921,6 +937,48 @@ test('renders and accepts every Ref strategy command at mobile width', async ({ await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') }); }); +test('shows and reserves the Ref spy command for a user on desktop and mobile', async ({ page }) => { + const requests = await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/'); + const editor = page.locator('[data-command-scope="general"]'); + await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + + let picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '군사', exact: true }).click(); + const spy = picker.getByRole('button', { name: '첩보', exact: true }); + await expect(spy).toBeVisible(); + await spy.hover(); + await spy.focus(); + await expect(spy).toBeFocused(); + await spy.click(); + const form = picker.getByTestId('command-argument-form'); + await expect(form.getByTestId('command-argument-guidance')).toContainText( + '선택한 도시에 첩보를 실행합니다.' + ); + await expect(form.getByTestId('command-argument-guidance')).toContainText( + '인접 도시에서는 더 많은 정보를 얻습니다.' + ); + await form.locator('select').selectOption('2'); + await picker.screenshot({ path: test.info().outputPath('spy-command-desktop-1200.png') }); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + await expect(editor.locator('.action-column > div').first()).toHaveText('【허창】에 첩보 실행'); + expect(JSON.stringify(requests)).toContain('"action":"che_첩보","args":{"destCityId":2}'); + + await page.setViewportSize({ width: 500, height: 900 }); + await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); + picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '군사', exact: true }).click(); + await expect(picker.getByRole('button', { name: '첩보', exact: true })).toBeVisible(); + const geometry = await picker.evaluate((element) => ({ + width: element.getBoundingClientRect().width, + horizontalOverflow: element.scrollWidth - element.clientWidth, + })); + expect(geometry.width).toBeLessThanOrEqual(500); + expect(geometry.horizontalOverflow).toBeLessThanOrEqual(0); + await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') }); +}); + test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page, }) => { diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 54ab7cd7..53c68c97 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -827,6 +827,62 @@ test('메인 장수 동향과 개인 전투 기록은 Ref 행 간격·색상· await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry); }); +test('개인턴·수뇌턴 실패 사유를 메인 개인 기록에 표시한다', async ({ page }) => { + const state: FixtureState = { + permission: 'head', + myset: 3, + settingMutations: [], + accessPages: [], + recentRecords: { + global: [], + general: [ + { + id: 19002, + text: '●1월:대상 도시가 아국이 아닙니다. 여포 발령 실패.', + createdAt: '2026-01-01T03:55:00.000Z', + }, + { + id: 19001, + text: '●1월:같은 도시입니다. 으로 이동 실패.', + createdAt: '2026-01-01T03:54:00.000Z', + }, + ], + history: [], + }, + }; + await install(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto(''); + + const inspectFailureLogs = async (selector: string) => { + const lines = page.locator(selector); + await expect(lines).toHaveCount(2); + await expect(lines.nth(0)).toContainText('대상 도시가 아국이 아닙니다. 여포 발령 실패. 12:55'); + await expect(lines.nth(1)).toContainText('같은 도시입니다. 업으로 이동 실패. 12:54'); + return lines.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + text: element.textContent?.trim(), + width: rect.width, + height: rect.height, + lineHeight: style.lineHeight, + }; + }) + ); + }; + + const desktop = await inspectFailureLogs('.record-zone [data-record-bucket="general"] .record-line'); + expect(desktop.every((line) => line.width > 0 && line.height === 21 && line.lineHeight === '21px')).toBe(true); + await persistParityArtifact(page, 'core-main-turn-failure-personal-records-desktop', desktop); + + await page.setViewportSize({ width: 500, height: 900 }); + const mobile = await inspectFailureLogs('.record-zone-mobile [data-record-bucket="general"] .record-line'); + expect(mobile.every((line) => line.width > 0 && line.height === 21 && line.lineHeight === '21px')).toBe(true); + await persistParityArtifact(page, 'core-main-turn-failure-personal-records-mobile', mobile); +}); + test('전투시드는 메인·내 정보·감찰부에서 숨긴 채 선택할 수 있다', async ({ page }) => { const seedText = '(전투시드: 0123456789abcdef)'; const logText = @@ -1231,7 +1287,9 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오 const actions = element.querySelector('.title-actions')!.getBoundingClientRect(); const navigation = element.querySelector('.navigation-actions')!.getBoundingClientRect(); const back = element.querySelector('.navigation-actions a')!.getBoundingClientRect(); - const refresh = element.querySelector('.navigation-actions button')!.getBoundingClientRect(); + const refresh = element + .querySelector('.navigation-actions button')! + .getBoundingClientRect(); const past = element.querySelector('.past-plays-link')!.getBoundingClientRect(); const pastStyle = getComputedStyle(element.querySelector('.past-plays-link')!); return { @@ -1269,6 +1327,77 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오 } }); +test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({ page }) => { + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; + await install(page, state); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('my-page'); + + await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' }); + await expect(dialog).toBeVisible(); + const readOrder = () => + dialog + .locator('[data-mobile-layout-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-layout-id'))); + const defaultOrder = [ + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + 'messages', + ]; + await expect.poll(readOrder).toEqual(defaultOrder); + + await dialog + .locator('[data-mobile-layout-id="messages"]') + .dragTo(dialog.locator('[data-mobile-layout-id="commands"]')); + await expect + .poll(readOrder) + .toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'city', 'map', 'records', 'global-menu']); + await dialog.getByRole('button', { name: '지도 위로' }).click(); + await expect + .poll(readOrder) + .toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'map', 'city', 'records', 'global-menu']); + + const dialogGeometry = await dialog.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const firstItem = element.querySelector('[data-mobile-layout-id]')?.getBoundingClientRect(); + const moveButton = element.querySelector('[aria-label$="아래로"]')?.getBoundingClientRect(); + return { + rect: rect.toJSON(), + firstItem: firstItem?.toJSON() ?? null, + moveButton: moveButton?.toJSON() ?? null, + overflowX: getComputedStyle(element).overflowX, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(dialogGeometry.rect.left).toBeGreaterThanOrEqual(0); + expect(dialogGeometry.rect.right).toBeLessThanOrEqual(390); + expect(dialogGeometry.firstItem?.height).toBeGreaterThanOrEqual(44); + expect(dialogGeometry.moveButton?.width).toBeGreaterThanOrEqual(36); + expect(dialogGeometry.documentWidth).toBe(390); + await persistParityArtifact(page, 'core-my-page-mobile-layout-order-dialog', dialogGeometry); + + await dialog.getByRole('button', { name: '적용', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect + .poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))) + .toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'map', 'city', 'records', 'global-menu']); + + await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); + await dialog.getByRole('button', { name: '기본값', exact: true }).click(); + await expect.poll(readOrder).toEqual(defaultOrder); + await dialog.getByRole('button', { name: '적용', exact: true }).click(); + await expect + .poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))) + .toEqual(defaultOrder); +}); + for (const [label, failure] of [ ['daemon timeout', 'TIMEOUT'], ['engine transaction 오류', 'INTERNAL_SERVER_ERROR'], diff --git a/app/game-frontend/e2e/legacyArchiveViews.spec.ts b/app/game-frontend/e2e/legacyArchiveViews.spec.ts index 3e02076b..12b1b42b 100644 --- a/app/game-frontend/e2e/legacyArchiveViews.spec.ts +++ b/app/game-frontend/e2e/legacyArchiveViews.spec.ts @@ -11,6 +11,7 @@ const isLegacyRequest = (route: Route): boolean => const installArchiveViews = async (page: Page) => { const hallRequests: string[] = []; + const dynastyRequests: string[] = []; await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_archive_views'); localStorage.setItem('sammo-game-profile', profile); @@ -21,6 +22,9 @@ const installArchiveViews = async (page: Page) => { if (operations.some((operation) => operation.startsWith('ranking.getHallOfFame'))) { hallRequests.push(decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`)); } + if (operations.some((operation) => operation.startsWith('dynasty.'))) { + dynastyRequests.push(decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`)); + } const results = operations.map((operation) => { if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '기록장수' } }); @@ -67,8 +71,8 @@ const installArchiveViews = async (page: Page) => { { id: legacy ? 101 : 1, source: legacy ? 'legacy' : 'current', - sourceProfile: legacy ? 'hwe' : 'che', - serverId: legacy ? 'hwe-old-1' : 'che-current-1', + sourceProfile: 'che', + serverId: legacy ? 'che-old-1' : 'che-current-1', phase: legacy ? '이전 1기' : '현재 1기', name: '촉', year: 215, @@ -93,10 +97,10 @@ const installArchiveViews = async (page: Page) => { if (operation === 'dynasty.getDetail') { return response({ source: 'legacy', - sourceProfile: 'hwe', + sourceProfile: 'che', emperor: { id: 101, - serverId: 'hwe-old-1', + serverId: 'che-old-1', winnerNationId: 1, phase: '이전 1기', nationCount: '1 / 2', @@ -189,7 +193,7 @@ const installArchiveViews = async (page: Page) => { }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); }); - return { hallRequests }; + return { dynastyRequests, hallRequests }; }; test('명예의 전당은 현재 profile의 현재·이전 서버 기록만 조회한다', async ({ page }, testInfo) => { @@ -217,19 +221,36 @@ test('명예의 전당은 현재 profile의 현재·이전 서버 기록만 조 await page.screenshot({ path: testInfo.outputPath('hall-profile-scope-mobile.png'), fullPage: true }); }); -test('왕조 일람과 상세는 이전 서버 source와 profile을 유지한다', async ({ page }) => { - await installArchiveViews(page); +test('왕조 일람과 상세는 현재 profile의 이전 서버 기록만 조회한다', async ({ page }, testInfo) => { + const state = await installArchiveViews(page); await page.setViewportSize({ width: 1200, height: 800 }); await page.goto('dynasty'); await expect(page.getByText('현재 1기')).toBeVisible(); + await page.getByLabel('기록 구분').focus(); + await expect(page.getByLabel('기록 구분')).toBeFocused(); await page.getByLabel('기록 구분').selectOption('legacy'); - await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible(); + await expect(page.getByText(/이전 1기.*이전 서버/)).toBeVisible(); + await expect(page.getByText(/CHE 이전 서버|HWE 이전 서버/)).toHaveCount(0); + await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px'); + await expect(page.locator('.dynasty-table')).toHaveCSS('height', '139px'); + await expect(page.locator('.dynasty-table .phase-heading')).toHaveCSS('background-color', 'rgb(135, 206, 235)'); + await page.screenshot({ path: testInfo.outputPath('dynasty-list-profile-scope-desktop.png'), fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px'); + await page.screenshot({ path: testInfo.outputPath('dynasty-list-profile-scope-mobile.png'), fullPage: true }); + + await page.setViewportSize({ width: 1200, height: 800 }); const detailLink = page.getByRole('link', { name: '자세히' }); await expect(detailLink).toHaveAttribute('href', /dynasty\/101\?source=legacy$/); await detailLink.click(); - await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible(); + await expect(page.getByText(/이전 1기.*이전 서버/)).toBeVisible(); + await expect(page.getByText(/CHE 이전 서버|HWE 이전 서버/)).toHaveCount(0); await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px'); + expect(state.dynastyRequests.some((request) => request.includes('legacy'))).toBe(true); + expect(state.dynastyRequests.every((request) => !request.includes('sourceProfile'))).toBe(true); + await page.screenshot({ path: testInfo.outputPath('dynasty-detail-profile-scope-desktop.png'), fullPage: true }); }); test('연감 국가 라벨은 밝은 배경에 검정, 어두운 배경에 흰 글자를 사용한다', async ({ page }, testInfo) => { diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 4e53a936..2d6be228 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -52,6 +52,8 @@ type NavigationFixture = { currentYear?: number; currentMonth?: number; serverId?: string; + profile?: string; + gameIdx?: number; scenarioTitle?: string; nationColor?: string; lastExecuted?: string | null; @@ -95,11 +97,10 @@ type DashboardBundleInput = { const operationInput = (route: Route, index: number): DashboardBundleInput => { const request = route.request(); const queryInput = new URL(request.url()).searchParams.get('input'); - const parsed = (request.postData() - ? request.postDataJSON() - : queryInput - ? JSON.parse(queryInput) - : {}) as Record; + const parsed = (request.postData() ? request.postDataJSON() : queryInput ? JSON.parse(queryInput) : {}) as Record< + string, + unknown + >; const entry = (parsed[String(index)] ?? parsed) as { json?: DashboardBundleInput }; return entry.json ?? (entry as DashboardBundleInput); }; @@ -253,32 +254,32 @@ const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = f general: draftCommands ? draftCommandGroups : refCategories - ? refCommandCategoryFixture - : large - ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ - category, - values: Array.from({ length: 16 }, (_, localIndex) => { - const index = categoryIndex * 16 + localIndex; - return { - key: `command-${index}`, - name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`, - reqArg: index % 2 === 0, - possible: index >= blockedCount, - status: index >= blockedCount ? 'available' : 'blocked', - inputFields: [ - { - key: 'amount', - label: '수량', - kind: 'number', - required: true, - min: 1, - max: 10_000, - }, - ], - }; - }), - })) - : [], + ? refCommandCategoryFixture + : large + ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ + category, + values: Array.from({ length: 16 }, (_, localIndex) => { + const index = categoryIndex * 16 + localIndex; + return { + key: `command-${index}`, + name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`, + reqArg: index % 2 === 0, + possible: index >= blockedCount, + status: index >= blockedCount ? 'available' : 'blocked', + inputFields: [ + { + key: 'amount', + label: '수량', + kind: 'number', + required: true, + min: 1, + max: 10_000, + }, + ], + }; + }), + })) + : [], nation: [], inputOptions: { cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })), @@ -500,7 +501,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { ? response({ id: 'user-7', username: 'menu-user', displayName: '메뉴 사용자' }) : operation === 'navigation.get' ? response(runtimeNavigation) - : response({ ok: true }) + : response({ ok: true }) ); await route.fulfill({ status: 200, @@ -530,6 +531,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => { return response({ myGeneral: { id: 7, name: '메뉴검증장수' }, serverId: state.serverId ?? 'che_fixture_season', + profile: state.profile ?? 'che', + gameIdx: state.gameIdx ?? 101, year: state.currentYear ?? 185, month: state.currentMonth ?? 1, turnTerm: 10, @@ -793,6 +796,103 @@ const gridColumnCount = async (page: Page, selector: string) => .first() .evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length); +const setMobilePanelOrder = async (page: Page, order: readonly string[]) => { + await page.evaluate((nextOrder) => { + localStorage.setItem('sam.mobileMainPanelOrder.v1', JSON.stringify(nextOrder)); + document.dispatchEvent(new CustomEvent('sam-mobile-main-panel-order-changed')); + }, order); +}; + +const inspectMobilePanelLayout = async (page: Page) => + page.locator('.layout-mobile').evaluate((container) => { + const containerStyle = getComputedStyle(container); + const panels = [...container.querySelectorAll(':scope > [data-mobile-panel-id]')].map( + (element, domIndex) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const content = element.firstElementChild as HTMLElement | null; + const contentStyle = content ? getComputedStyle(content) : null; + return { + id: element.dataset.mobilePanelId ?? '', + domIndex, + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.right, + width: rect.width, + height: rect.height, + display: style.display, + position: style.position, + inset: [style.top, style.right, style.bottom, style.left], + order: style.order, + transform: style.transform, + float: style.cssFloat, + gridRow: `${style.gridRowStart} / ${style.gridRowEnd}`, + gridColumn: `${style.gridColumnStart} / ${style.gridColumnEnd}`, + marginTop: style.marginTop, + marginBottom: style.marginBottom, + content: contentStyle + ? { + position: contentStyle.position, + order: contentStyle.order, + transform: contentStyle.transform, + marginTop: contentStyle.marginTop, + marginBottom: contentStyle.marginBottom, + height: contentStyle.height, + } + : null, + }; + } + ); + return { + container: { + display: containerStyle.display, + flexDirection: containerStyle.flexDirection, + position: containerStyle.position, + transform: containerStyle.transform, + }, + panels, + visualOrder: [...panels] + .sort((left, right) => left.top - right.top || left.left - right.left) + .map(({ id }) => id), + }; + }); + +const expectMobilePanelVisualOrder = async (page: Page, expectedOrder: readonly string[]) => { + await expect + .poll(() => + page + .locator('.layout-mobile > [data-mobile-panel-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-panel-id'))) + ) + .toEqual(expectedOrder); + const audit = await inspectMobilePanelLayout(page); + expect(audit.container).toEqual({ + display: 'flex', + flexDirection: 'column', + position: 'static', + transform: 'none', + }); + expect(audit.panels.map(({ id }) => id)).toEqual(expectedOrder); + expect(audit.visualOrder).toEqual(expectedOrder); + expect(audit.panels.every(({ left, right, width }) => left >= 0 && right <= 500 && width === 500)).toBe(true); + expect( + audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom) + ).toBe(true); + for (const panel of audit.panels) { + expect(panel.display, `${panel.id}: display`).not.toBe('none'); + expect(['static', 'relative'], `${panel.id}: position`).toContain(panel.position); + expect( + panel.inset.every((value) => value === 'auto' || value === '0px'), + `${panel.id}: inset ${panel.inset.join(' ')}` + ).toBe(true); + expect(panel.order, `${panel.id}: order`).toBe('0'); + expect(panel.transform, `${panel.id}: transform`).toBe('none'); + expect(panel.float, `${panel.id}: float`).toBe('none'); + } + return audit; +}; + const raisedButtonState = async (target: Locator) => target.evaluate((element) => { const rect = element.getBoundingClientRect(); @@ -1016,7 +1116,9 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(page.locator('.main-mobile-bottom')).toBeHidden(); await expect(page.locator('.layout-desktop')).toBeVisible(); await expect(page.locator('.layout-mobile')).toHaveCount(0); - await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1); + await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount( + 1 + ); await expect(page.locator('.game-shell__subtitle')).toHaveCount(0); await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월'); await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분'); @@ -1168,6 +1270,56 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); +test('shows the persisted official game index beside the scenario title without viewport overflow', async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + profile: 'hwe', + gameIdx: 7, + scenarioTitle: '메인 화면 검증 시나리오', + generalMeCalls: 0, + operations: [], + }; + await installFixture(page, state); + if (artifactRoot) await mkdir(resolve(artifactRoot), { recursive: true }); + + for (const viewport of [ + { width: 1200, height: 900 }, + { width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + if (page.url() === 'about:blank') await waitForMain(page); + + const title = page.getByRole('heading', { name: '메인 화면 검증 시나리오 훼섭 7기', exact: true }); + await expect(title).toBeVisible(); + const geometry = await title.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const mainRect = element.closest('.main-page')?.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + left: rect.left, + right: rect.right, + mainLeft: mainRect?.left, + mainRight: mainRect?.right, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); + expect(geometry.left).toBeGreaterThanOrEqual(geometry.mainLeft ?? 0); + expect(geometry.right).toBeLessThanOrEqual(geometry.mainRight ?? viewport.width); + expect(geometry.documentOverflow).toBeLessThanOrEqual(0); + expect(geometry.fontSize).toBe('25.6px'); + expect(geometry.lineHeight).toBe('38.4px'); + expect(geometry.fontFamily).toContain('Pretendard'); + await persistArtifact(page, `official-game-index-${viewport.width}`); + } +}); + test('nation split buttons keep square inner corners and a single divider in every interaction state', async ({ page, }, testInfo) => { @@ -1558,10 +1710,7 @@ test('message targets keep reply behavior and use nation-color contrast in label await page.setViewportSize({ width: 500, height: 900 }); const mobilePanel = page.locator('.mobile-message-panel'); - await expect(mobilePanel.locator('.msg-plate[data-id="101"] .msg-target')).toHaveCSS( - 'color', - 'rgb(255, 255, 255)' - ); + await expect(mobilePanel.locator('.msg-plate[data-id="101"] .msg-target')).toHaveCSS('color', 'rgb(255, 255, 255)'); await expect(mobilePanel.locator('.msg-plate[data-id="103"] .msg-target')).toHaveCSS('color', 'rgb(0, 0, 0)'); await expect(mobilePanel.locator('#mailbox_list optgroup[label="밝은국"]')).toHaveCSS('color', 'rgb(0, 0, 0)'); await persistArtifact(page, `${basePath.slice(1)}-message-nation-contrast-mobile-500`); @@ -2146,7 +2295,7 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy await expect(page.locator('.main-mobile-bottom')).toBeVisible(); await page.setViewportSize({ width: 500, height: 900 }); - await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1); + await expect(page.getByRole('heading', { name: '모바일 검증 시나리오 체섭 101기', exact: true })).toHaveCount(1); await expect(page.locator('.game-shell__subtitle')).toHaveCount(0); await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월'); await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분'); @@ -2217,6 +2366,45 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy ]) { await expect(page.locator(selector)).toBeVisible(); } + const defaultOrder = [ + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + 'messages', + ]; + const customOrder = [ + 'messages', + 'map', + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'records', + 'global-menu', + ]; + const reverseOrder = [...defaultOrder].reverse(); + const mobilePanelAudits = { + default: await expectMobilePanelVisualOrder(page, defaultOrder), + custom: null as Awaited> | null, + reverse: null as Awaited> | null, + }; + await setMobilePanelOrder(page, customOrder); + mobilePanelAudits.custom = await expectMobilePanelVisualOrder(page, customOrder); + await setMobilePanelOrder(page, reverseOrder); + mobilePanelAudits.reverse = await expectMobilePanelVisualOrder(page, reverseOrder); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await writeFile( + resolve(artifactRoot, `${basePath.slice(1)}-mobile-panel-css-order-audit.json`), + `${JSON.stringify(mobilePanelAudits, null, 2)}\n` + ); + } await persistArtifact(page, `${basePath.slice(1)}-mobile-500`); }); @@ -2517,6 +2705,27 @@ test('real mobile devices initially fit the complete 500px game canvas', async ( expect(mainGeometry.documentScrollWidth).toBeLessThanOrEqual(mainGeometry.innerWidth); expect(mainGeometry.canvas).toEqual({ left: 0, right: 500, width: 500 }); expect(mainGeometry.canvas.right).toBeLessThanOrEqual((mainGeometry.visualViewportWidth ?? 0) + 0.01); + let physicalPanelOrderAudit: unknown = null; + if (deviceWidth === 390) { + const defaultOrder = [ + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + 'messages', + ]; + const reverseOrder = [...defaultOrder].reverse(); + const defaultAudit = await expectMobilePanelVisualOrder(mobilePage, defaultOrder); + await setMobilePanelOrder(mobilePage, reverseOrder); + const reverseAudit = await expectMobilePanelVisualOrder(mobilePage, reverseOrder); + physicalPanelOrderAudit = { default: defaultAudit, reverse: reverseAudit }; + await setMobilePanelOrder(mobilePage, defaultOrder); + await expectMobilePanelVisualOrder(mobilePage, defaultOrder); + } if (artifactRoot) { await mkdir(artifactRoot, { recursive: true }); await mobilePage.screenshot({ @@ -2560,7 +2769,11 @@ test('real mobile devices initially fit the complete 500px game canvas', async ( } } - measurements[String(deviceWidth)] = { main: mainGeometry, routes: routeGeometry }; + measurements[String(deviceWidth)] = { + main: mainGeometry, + mobilePanelOrder: physicalPanelOrderAudit, + routes: routeGeometry, + }; await context.close(); } @@ -3040,9 +3253,9 @@ for (const viewport of [ await refreshActivityAndCommands(); await expect(picker.getByLabel('장비 종류', { exact: true })).toHaveValue('weapon'); await expect(picker.getByLabel('장비', { exact: true })).toHaveValue('청룡언월도'); - await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( - viewport.width - ); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth)) + .toBeLessThanOrEqual(viewport.width); }); } diff --git a/app/game-frontend/src/utils/mobileMainPanelOrder.ts b/app/game-frontend/src/utils/mobileMainPanelOrder.ts new file mode 100644 index 00000000..b337c833 --- /dev/null +++ b/app/game-frontend/src/utils/mobileMainPanelOrder.ts @@ -0,0 +1,85 @@ +export const MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY = 'sam.mobileMainPanelOrder.v1'; +export const MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT = 'sam-mobile-main-panel-order-changed'; + +export const MOBILE_MAIN_PANEL_DEFINITIONS = [ + { id: 'commands', label: '명령 목록' }, + { id: 'nation-menu', label: '국가 메뉴' }, + { id: 'nation', label: '국가 정보' }, + { id: 'general', label: '장수 정보' }, + { id: 'city', label: '도시 정보' }, + { id: 'map', label: '지도' }, + { id: 'records', label: '기록 영역' }, + { id: 'global-menu', label: '공통 메뉴' }, + { id: 'messages', label: '서신' }, +] as const; + +export type MobileMainPanelId = (typeof MOBILE_MAIN_PANEL_DEFINITIONS)[number]['id']; + +export const DEFAULT_MOBILE_MAIN_PANEL_ORDER: readonly MobileMainPanelId[] = MOBILE_MAIN_PANEL_DEFINITIONS.map( + ({ id }) => id +); + +const mobilePanelIds = new Set(DEFAULT_MOBILE_MAIN_PANEL_ORDER); + +export const normalizeMobileMainPanelOrder = (value: unknown): MobileMainPanelId[] => { + const source = Array.isArray(value) ? value : []; + const seen = new Set(); + const normalized: MobileMainPanelId[] = []; + + for (const item of source) { + if (typeof item !== 'string' || !mobilePanelIds.has(item) || seen.has(item)) continue; + seen.add(item); + normalized.push(item as MobileMainPanelId); + } + + for (const item of DEFAULT_MOBILE_MAIN_PANEL_ORDER) { + if (!seen.has(item)) normalized.push(item); + } + + return normalized; +}; + +export const parseMobileMainPanelOrder = (raw: string | null): MobileMainPanelId[] => { + if (!raw) return [...DEFAULT_MOBILE_MAIN_PANEL_ORDER]; + try { + return normalizeMobileMainPanelOrder(JSON.parse(raw)); + } catch { + return [...DEFAULT_MOBILE_MAIN_PANEL_ORDER]; + } +}; + +export const loadMobileMainPanelOrder = (storage: Pick = window.localStorage) => + parseMobileMainPanelOrder(storage.getItem(MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY)); + +export const saveMobileMainPanelOrder = ( + value: readonly MobileMainPanelId[], + storage: Pick = window.localStorage +): MobileMainPanelId[] => { + const normalized = normalizeMobileMainPanelOrder(value); + storage.setItem(MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY, JSON.stringify(normalized)); + if (typeof document !== 'undefined') { + document.dispatchEvent(new CustomEvent(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT)); + } + return normalized; +}; + +export const moveMobileMainPanel = ( + value: readonly MobileMainPanelId[], + fromIndex: number, + toIndex: number +): MobileMainPanelId[] => { + const normalized = normalizeMobileMainPanelOrder(value); + if ( + fromIndex < 0 || + fromIndex >= normalized.length || + toIndex < 0 || + toIndex >= normalized.length || + fromIndex === toIndex + ) { + return normalized; + } + const [moved] = normalized.splice(fromIndex, 1); + if (!moved) return normalized; + normalized.splice(toIndex, 0, moved); + return normalized; +}; diff --git a/app/game-frontend/src/views/DynastyDetailView.vue b/app/game-frontend/src/views/DynastyDetailView.vue index ce963b3c..cd743757 100644 --- a/app/game-frontend/src/views/DynastyDetailView.vue +++ b/app/game-frontend/src/views/DynastyDetailView.vue @@ -92,9 +92,7 @@ onMounted(loadDetail); {{ data.emperor.phase }} - + diff --git a/app/game-frontend/src/views/DynastyListView.vue b/app/game-frontend/src/views/DynastyListView.vue index cec29466..88da66cf 100644 --- a/app/game-frontend/src/views/DynastyListView.vue +++ b/app/game-frontend/src/views/DynastyListView.vue @@ -98,9 +98,7 @@ watch(selectedSource, loadDynasty); {{ entry.phase - }} [이전 서버] -import { formatServerDateTime } from '@sammo-ts/common'; +import { formatServerDateTime, JosaUtil } from '@sammo-ts/common'; import { computed, onMounted, reactive, ref } from 'vue'; import { trpc } from '../utils/trpc'; type InheritStatus = Awaited>; type InheritLog = Awaited>[number]; type JoinConfig = Awaited>; +type UniqueItemSlot = InheritStatus['availableUnique'][number]['slot']; type BuffKey = | 'warAvoidRatio' @@ -67,6 +68,14 @@ const pointOrder = [ 'betting', ] as const; +const uniqueItemSlotOrder: readonly UniqueItemSlot[] = ['horse', 'weapon', 'book', 'item']; +const uniqueItemSlotLabels: Record = { + horse: '명마', + weapon: '무기', + book: '서적', + item: '도구', +}; + const pointHelp: Record = { previous: '이전에 물려받은 포인트입니다.', lived_month: '살아남은 기간입니다. (1개월 단위)', @@ -196,6 +205,15 @@ const specialNameMap = computed(() => { const selectedSpecialWarInfo = computed( () => status.value?.availableSpecialWar.find((entry) => entry.key === nextSpecialKey.value)?.info ?? '' ); +const availableUniqueGroups = computed(() => + uniqueItemSlotOrder + .map((slot) => ({ + slot, + label: uniqueItemSlotLabels[slot], + items: status.value?.availableUnique.filter((item) => item.slot === slot) ?? [], + })) + .filter((group) => group.items.length > 0) +); const buffCost = (key: BuffKey, target: number): number => { const points = status.value?.inheritConst.inheritBuffPoints ?? [0, 0, 0, 0, 0, 0]; @@ -379,7 +397,8 @@ const buyRandomUnique = async () => { }; const openUniqueAuction = async () => { - if (!uniqueForm.itemId.trim()) { + const selectedItem = status.value?.availableUnique.find((item) => item.key === uniqueForm.itemId.trim()); + if (!selectedItem) { actionError.value = '유니크를 선택해주세요.'; return; } @@ -388,15 +407,20 @@ const openUniqueAuction = async () => { actionError.value = '입찰 포인트를 입력해주세요.'; return; } - if (!window.confirm(`유니크 경매를 ${amount} 포인트로 신청하시겠습니까?`)) { + if (previousPoint.value < amount) { + actionError.value = '유산 포인트가 부족합니다.'; + return; + } + const itemJosa = JosaUtil.pick(selectedItem.rawName, '을'); + if (!window.confirm(`${amount} 포인트로 ${selectedItem.name}${itemJosa} 입찰하겠습니까?`)) { return; } await runAction(async () => { await trpc.inherit.openUniqueAuction.mutate({ - itemId: uniqueForm.itemId.trim(), + itemId: selectedItem.key, amount, }); - }); + }, '성공했습니다. 경매장을 확인해주세요.'); }; const checkOwner = async () => { @@ -512,9 +536,11 @@ onMounted(() => {
diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 45c72071..94f08bb8 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -29,6 +29,12 @@ import { useMainDashboardStore } from '../stores/mainDashboard'; import { useGameFeedback } from '../composables/useGameFeedback'; import { trpc } from '../utils/trpc'; import type { CommandPatternEntry } from '../components/command/types'; +import { + loadMobileMainPanelOrder, + MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT, + MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY, + type MobileMainPanelId, +} from '../utils/mobileMainPanelOrder'; const session = useSessionStore(); const dashboard = useMainDashboardStore(); @@ -38,8 +44,20 @@ const isMobile = useMediaQuery('(max-width: 939.98px)'); const npcMode = ref(0); const globalNavigation = ref(defaultGlobalNavigation); const versionDialog = ref(null); +const mobilePanelOrder = ref(loadMobileMainPanelOrder()); const navigationUrl = (import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc').replace(/\/trpc\/?$/u, '/navigation'); +const reloadMobilePanelOrder = () => { + mobilePanelOrder.value = loadMobileMainPanelOrder(); +}; +const handleMobilePanelStorage = (event: StorageEvent) => { + if (event.key === MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY) reloadMobilePanelOrder(); +}; +const isFlushMobilePanel = (panelId: MobileMainPanelId, index: number): boolean => { + const previous = mobilePanelOrder.value[index - 1]; + return (panelId === 'general' && previous === 'nation') || (panelId === 'city' && previous === 'general'); +}; + const { loading, refreshing, @@ -77,6 +95,27 @@ const nationAccess = computed(() => ({ })); const nationColor = computed(() => nation.value?.color ?? '#000000'); const voteActive = computed(() => Boolean(frontStatus.value?.latestVote)); +const profileLabels: Record = { + che: '체', + kwe: '퀘', + pwe: '풰', + twe: '퉤', + nya: '냐', + pya: '퍄', + hwe: '훼', +}; +const gameProfileLabel = computed(() => { + const profile = lobbyInfo.value?.profile?.trim(); + return profile ? (profileLabels[profile] ?? profile) : ''; +}); +const gameTitle = computed(() => { + const scenarioTitle = lobbyInfo.value?.scenarioTitle || '전장 현황'; + const profileLabel = gameProfileLabel.value; + const gameIdx = lobbyInfo.value?.gameIdx; + return profileLabel && typeof gameIdx === 'number' && Number.isInteger(gameIdx) && gameIdx > 0 + ? `${scenarioTitle} ${profileLabel}섭 ${gameIdx}기` + : scenarioTitle; +}); const recordTimeSuffixPattern = /\d{2}:\d{2}(?:<\/>)?\s*$/u; const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => { if (!appendTime || recordTimeSuffixPattern.test(entry.text)) return formatLog(entry.text); @@ -100,10 +139,14 @@ onUnmounted(() => { clearTimeout(surveyNoticeTimer); } dashboard.stopRealtime(); + window.removeEventListener('storage', handleMobilePanelStorage); + document.removeEventListener(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT, reloadMobilePanelOrder); }); onMounted(() => { dashboard.startRealtime(); + window.addEventListener('storage', handleMobilePanelStorage); + document.addEventListener(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT, reloadMobilePanelOrder); void fetch(navigationUrl, { headers: { Accept: 'application/json' } }) .then(async (response) => { if (!response.ok) throw new Error(`메뉴 설정 조회 실패: HTTP ${response.status}`); @@ -130,10 +173,7 @@ const repeatGeneralTurns = (amount: number) => { }; const loadMainData = async () => { - const [, worldState] = await Promise.all([ - dashboard.loadMainData(), - trpc.world.getState.query().catch(() => null), - ]); + const [, worldState] = await Promise.all([dashboard.loadMainData(), trpc.world.getState.query().catch(() => null)]); npcMode.value = worldState?.config.npcMode ?? 0; }; @@ -180,7 +220,7 @@ watch(

- {{ lobbyInfo?.scenarioTitle || '전장 현황' }} + {{ gameTitle }}

+
+ + 모바일 레이아웃 순서 바꾸기
+ 500px 메인 화면의 패널 순서를 이 기기에 저장합니다. +
+ +
+
아이템 파기
+ +
+

항목을 끌어 놓거나 위·아래 버튼으로 상대 순서를 바꿉니다.

+
    +
  1. + + + {{ index + 1 }} + {{ mobileLayoutLabels[panelId] }} + + + + + +
  2. +
+
+ +
+ +
+ @@ -822,6 +935,125 @@ button:disabled { align-items: center; margin: 14px 0; } +.mobile-layout-setting-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 128px; + align-items: center; + gap: 8px; + margin: 14px 0; +} +.mobile-layout-setting-row small { + color: orange; +} +.mobile-layout-open { + min-height: 34px; + background: #315f86; + font-weight: 700; +} +.mobile-layout-dialog { + box-sizing: border-box; + width: min(460px, calc(100vw - 24px)); + max-height: calc(100dvh - 24px); + margin: auto; + overflow: auto; + border: 1px solid #777; + border-radius: 4px; + padding: 12px; + background: #171717 var(--sammo-texture-walnut); + color: #fff; + font: 14px/1.3 var(--sammo-font-sans); +} +.mobile-layout-dialog::backdrop { + background: rgb(0 0 0 / 72%); +} +.mobile-layout-dialog__header, +.mobile-layout-dialog__actions, +.mobile-layout-move-buttons { + display: flex; + align-items: center; +} +.mobile-layout-dialog__header { + justify-content: space-between; + gap: 12px; +} +.mobile-layout-dialog__header h2, +.mobile-layout-dialog p { + margin: 0 0 10px; +} +.mobile-layout-dialog__header h2 { + color: skyblue; + font-size: 18px; +} +.mobile-layout-dialog__header form, +.mobile-layout-dialog__actions form { + margin: 0; +} +.mobile-layout-dialog__header button { + min-width: 32px; + min-height: 32px; + font-size: 20px; +} +.mobile-layout-list { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} +.mobile-layout-list > li { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + min-height: 44px; + align-items: center; + border: 1px solid #777; + background: #172a52 var(--sammo-texture-blue); + cursor: grab; +} +.mobile-layout-list > li:active { + cursor: grabbing; +} +.mobile-layout-handle { + color: #aaa; + text-align: center; + font-size: 20px; +} +.mobile-layout-label { + min-width: 0; + font-weight: 700; +} +.mobile-layout-position { + display: inline-grid; + width: 22px; + height: 22px; + place-items: center; + margin-right: 4px; + border: 1px solid #7186a7; + border-radius: 50%; + font-size: 12px; +} +.mobile-layout-move-buttons { + gap: 4px; + padding-right: 5px; +} +.mobile-layout-move-buttons button { + width: 36px; + min-height: 34px; + background: #315f86; + font-weight: 700; +} +.mobile-layout-dialog__actions { + justify-content: flex-end; + gap: 6px; + margin-top: 12px; +} +.mobile-layout-dialog__actions button { + min-height: 34px; + padding: 4px 10px; +} +.mobile-layout-dialog__actions .mobile-layout-apply { + background: #225500; + font-weight: 700; +} .button-group { display: flex; } @@ -933,6 +1165,12 @@ button:disabled { grid-template-columns: 1fr; gap: 6px; } + .mobile-layout-setting-row { + grid-template-columns: 1fr; + } + .mobile-layout-open { + width: 100%; + } .button-group { overflow-x: auto; } diff --git a/app/game-frontend/test/mobileMainPanelOrder.test.ts b/app/game-frontend/test/mobileMainPanelOrder.test.ts new file mode 100644 index 00000000..288f2b4c --- /dev/null +++ b/app/game-frontend/test/mobileMainPanelOrder.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + DEFAULT_MOBILE_MAIN_PANEL_ORDER, + MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY, + loadMobileMainPanelOrder, + moveMobileMainPanel, + normalizeMobileMainPanelOrder, + parseMobileMainPanelOrder, + saveMobileMainPanelOrder, +} from '../src/utils/mobileMainPanelOrder.ts'; + +void test('uses the Ref mobile panel order as the default', () => { + assert.deepEqual(DEFAULT_MOBILE_MAIN_PANEL_ORDER, [ + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + 'messages', + ]); + assert.deepEqual(parseMobileMainPanelOrder(null), DEFAULT_MOBILE_MAIN_PANEL_ORDER); +}); + +void test('keeps known unique entries and appends newly introduced panels', () => { + assert.deepEqual(normalizeMobileMainPanelOrder(['messages', 'commands', 'messages', 'unknown']), [ + 'messages', + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + ]); + assert.deepEqual(parseMobileMainPanelOrder('{broken'), DEFAULT_MOBILE_MAIN_PANEL_ORDER); +}); + +void test('moves and persists the normalized order', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + const moved = moveMobileMainPanel(DEFAULT_MOBILE_MAIN_PANEL_ORDER, 8, 0); + assert.equal(moved[0], 'messages'); + assert.deepEqual(saveMobileMainPanelOrder(moved, storage), moved); + assert.equal(values.has(MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY), true); + assert.deepEqual(loadMobileMainPanelOrder(storage), moved); +}); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 8118680f..4cd21d8a 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -44,7 +44,11 @@ import { writeProfileReleaseSource, type ProfileReleaseSource, } from './profileReleaseSource.js'; -import type { GitWorkspaceManager } from './workspaceManager.js'; +import { + DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST, + DEFAULT_MANAGED_WORKSPACE_RETENTION_MS, + type GitWorkspaceManager, +} from './workspaceManager.js'; import type { AdminSeedUser } from './seedProfileDatabase.js'; import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.js'; @@ -73,6 +77,8 @@ export interface GatewayOrchestratorOptions { cancelGame?: typeof defaultCancelGame; } +const WORKSPACE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1_000; + export interface ProfileRuntimeState { frontendRunning: boolean; apiRunning: boolean; @@ -629,11 +635,13 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private scheduleTimer?: NodeJS.Timeout; private buildTimer?: NodeJS.Timeout; private adminActionTimer?: NodeJS.Timeout; + private workspaceCleanupTimer?: NodeJS.Timeout; private reconcileInFlight = false; private scheduleInFlight = false; private buildInFlight = false; private adminActionInFlight = false; private operationInFlight = false; + private workspaceCleanupInFlight = false; private activeOperationAbortSignal?: AbortSignal; private readonly resetInFlight = new Set(); private readonly operationLeaseOwner = randomUUID(); @@ -714,7 +722,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { start(): void { this.stopping = false; this.trackTask(this.reconcileNow()); - this.trackTask(this.runOperationsNow()); + this.trackTask(this.runOperationsNow().then(() => this.cleanupWorkspacesScheduled())); this.trackTask(this.runAdminActionsNow()); this.reconcileTimer = setInterval(() => this.trackTask(this.reconcileNow()), this.reconcileIntervalMs); this.scheduleTimer = setInterval(() => this.trackTask(this.runScheduleNow()), this.scheduleIntervalMs); @@ -723,6 +731,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { this.trackTask(this.runOperationsNow()); this.trackTask(this.runAdminActionsNow()); }, this.adminActionIntervalMs); + this.workspaceCleanupTimer = setInterval( + () => this.trackTask(this.cleanupWorkspacesScheduled()), + WORKSPACE_CLEANUP_INTERVAL_MS + ); } async stop(): Promise { @@ -747,6 +759,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { if (this.adminActionTimer) { clearInterval(this.adminActionTimer); } + if (this.workspaceCleanupTimer) { + clearInterval(this.workspaceCleanupTimer); + } await Promise.allSettled([...this.inFlightTasks]); } @@ -903,7 +918,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } async runBuildQueueNow(): Promise { - if (this.stopping || this.buildInFlight) { + if (this.stopping || this.buildInFlight || this.workspaceCleanupInFlight) { return; } this.buildInFlight = true; @@ -965,7 +980,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } async runOperationsNow(): Promise { - if (this.stopping || this.operationInFlight || this.buildInFlight) { + if (this.stopping || this.operationInFlight || this.buildInFlight || this.workspaceCleanupInFlight) { return; } this.operationInFlight = true; @@ -2160,83 +2175,56 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> { - const profiles = await this.repository.listProfiles(); - const cutoff = this.computeCutoffDate(6); - const workspaceMap = new Map(); - for (const profile of profiles) { - const workspace = profile.buildWorkspace; - if (!workspace) { - continue; - } - const entry = workspaceMap.get(workspace) ?? { - profileNames: [], - lastUsedAt: undefined, - hasActiveBuild: false, - }; - entry.profileNames.push(profile.profileName); - if (profile.buildLastUsedAt) { - const usedAt = new Date(profile.buildLastUsedAt); - if (!entry.lastUsedAt || usedAt > entry.lastUsedAt) { - entry.lastUsedAt = usedAt; + if (this.buildInFlight || this.operationInFlight || this.workspaceCleanupInFlight) { + const managedWorkspaces = await this.workspaceManager.listManagedWorkspaces(); + return { removed: [], skipped: managedWorkspaces.map((workspace) => workspace.root) }; + } + this.workspaceCleanupInFlight = true; + try { + const managedWorkspaces = await this.workspaceManager.listManagedWorkspaces(); + const profiles = await this.repository.listProfiles(); + const protectedWorkspaces = new Set(); + for (const profile of profiles) { + if (profile.buildWorkspace) { + protectedWorkspaces.add(path.resolve(profile.buildWorkspace)); + } + if (profile.buildCommitSha && (profile.buildStatus === 'RUNNING' || profile.buildStatus === 'QUEUED')) { + protectedWorkspaces.add( + path.resolve(this.workspaceManager.workspacePathForCommit(profile.buildCommitSha)) + ); } } - if (profile.buildStatus === 'RUNNING' || profile.buildStatus === 'QUEUED') { - entry.hasActiveBuild = true; - } - workspaceMap.set(workspace, entry); - } - const activeProcesses = (await this.processManager.list()).filter((process) => - isRuntimeProcessActive(process.status) - ); - const referencedWorkspaces = new Set(); - for (const [workspace, entry] of workspaceMap.entries()) { - const profileProcessNames = new Set( - entry.profileNames.flatMap((profileName) => [ - buildProcessName(profileName, 'frontend'), - buildProcessName(profileName, 'api'), - buildProcessName(profileName, 'daemon'), - buildProcessName(profileName, 'auction'), - buildProcessName(profileName, 'battle-sim'), - buildProcessName(profileName, 'tournament'), - ]) + const activeProcesses = (await this.processManager.list()).filter((process) => + isRuntimeProcessActive(process.status) ); - if ( - activeProcesses.some( - (process) => - profileProcessNames.has(process.name) || - isPathInside(process.cwd, workspace) || - isPathInside(process.script, workspace) - ) - ) { - referencedWorkspaces.add(workspace); + for (const workspace of managedWorkspaces) { + if ( + activeProcesses.some( + (process) => + isPathInside(process.cwd, workspace.root) || isPathInside(process.script, workspace.root) + ) + ) { + protectedWorkspaces.add(workspace.root); + } } - } - const removed: string[] = []; - const skipped: string[] = []; - for (const [workspace, entry] of workspaceMap.entries()) { - if (!entry.lastUsedAt || entry.hasActiveBuild || referencedWorkspaces.has(workspace)) { - skipped.push(workspace); - continue; - } - if (entry.lastUsedAt > cutoff) { - skipped.push(workspace); - continue; - } - await this.workspaceManager.remove(workspace); - await this.repository.clearWorkspaceUsage(entry.profileNames); - removed.push(workspace); + return await this.workspaceManager.cleanup({ + protectedPaths: [...protectedWorkspaces], + retentionMs: DEFAULT_MANAGED_WORKSPACE_RETENTION_MS, + keepNewest: DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST, + }); + } finally { + this.workspaceCleanupInFlight = false; } - - return { removed, skipped }; } - private computeCutoffDate(months: number): Date { - const date = this.now(); - const cutoff = new Date(date); - cutoff.setMonth(cutoff.getMonth() - months); - return cutoff; + private async cleanupWorkspacesScheduled(): Promise { + if (this.stopping || this.buildInFlight || this.operationInFlight || this.workspaceCleanupInFlight) return; + const result = await this.cleanupStaleWorkspaces(); + if (result.removed.length > 0) { + console.info(`[gateway-orchestrator] removed ${result.removed.length} stale profile worktrees`); + } } private async startProfile(profile: GatewayProfileRecord, assertLease?: () => Promise): Promise { diff --git a/app/gateway-api/src/orchestrator/workspaceManager.ts b/app/gateway-api/src/orchestrator/workspaceManager.ts index be1fc191..5257bcfe 100644 --- a/app/gateway-api/src/orchestrator/workspaceManager.ts +++ b/app/gateway-api/src/orchestrator/workspaceManager.ts @@ -6,6 +6,7 @@ export interface WorkspaceManagerOptions { repoRoot: string; worktreeRoot: string; baseEnv?: Record; + now?: () => Date; } export interface WorkspaceInfo { @@ -14,6 +15,26 @@ export interface WorkspaceInfo { needsInstall: boolean; } +export interface ManagedWorkspaceInfo { + root: string; + commitSha: string; + lastUsedAt: Date; +} + +export interface ManagedWorkspaceCleanupOptions { + protectedPaths?: readonly string[]; + retentionMs: number; + keepNewest: number; +} + +export interface ManagedWorkspaceCleanupResult { + removed: string[]; + skipped: string[]; +} + +export const DEFAULT_MANAGED_WORKSPACE_RETENTION_MS = 24 * 60 * 60 * 1_000; +export const DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST = 2; + const runGit = (args: string[], cwd: string, env?: Record): Promise<{ ok: boolean; output: string }> => new Promise((resolve) => { const child = spawn('git', args, { @@ -58,11 +79,13 @@ export class GitWorkspaceManager { private readonly repoRoot: string; private readonly worktreeRoot: string; private readonly baseEnv?: Record; + private readonly now: () => Date; constructor(options: WorkspaceManagerOptions) { this.repoRoot = options.repoRoot; this.worktreeRoot = options.worktreeRoot; this.baseEnv = options.baseEnv; + this.now = options.now ?? (() => new Date()); } async resolveCommit(sourceMode: 'BRANCH' | 'COMMIT', sourceRef: string): Promise { @@ -124,6 +147,8 @@ export class GitWorkspaceManager { } else { await this.assertReusableWorkspace(workspacePath, commitSha); } + const usedAt = this.now(); + fs.utimesSync(workspacePath, usedAt, usedAt); return { root: workspacePath, @@ -138,13 +163,100 @@ export class GitWorkspaceManager { return false; } await this.assertRegisteredWorkspace(resolved); + const status = await runGit(['status', '--porcelain'], resolved, this.baseEnv); + if (!status.ok) { + throw new Error(status.output || 'Failed to inspect managed workspace.'); + } + if (status.output.trim()) { + throw new Error('Managed workspace has uncommitted changes.'); + } const result = await runGit(['worktree', 'remove', '--force', resolved], this.repoRoot, this.baseEnv); if (!result.ok) { - fs.rmSync(resolved, { recursive: true, force: true }); + throw new Error(result.output || 'Failed to remove git worktree.'); } return true; } + workspacePathForCommit(commitSha: string): string { + if (!COMMIT_SHA_PATTERN.test(commitSha)) { + throw new Error('Invalid commit SHA.'); + } + return path.join(this.worktreeRoot, commitSha); + } + + async listManagedWorkspaces(): Promise { + const listed = await runGit(['worktree', 'list', '--porcelain'], this.repoRoot, this.baseEnv); + if (!listed.ok) { + throw new Error(listed.output || 'Failed to inspect git worktrees.'); + } + const workspaces: ManagedWorkspaceInfo[] = []; + for (const block of listed.output.split(/\n\n+/)) { + const lines = block.split('\n'); + const worktreeLine = lines.find((line) => line.startsWith('worktree ')); + const headLine = lines.find((line) => line.startsWith('HEAD ')); + if (!worktreeLine || !headLine) continue; + const workspacePath = path.resolve(worktreeLine.slice('worktree '.length)); + const commitSha = headLine.slice('HEAD '.length); + try { + this.assertManagedWorkspacePath(workspacePath); + } catch { + continue; + } + if (!COMMIT_SHA_PATTERN.test(commitSha) || !fs.existsSync(workspacePath)) continue; + workspaces.push({ + root: workspacePath, + commitSha, + lastUsedAt: fs.statSync(workspacePath).mtime, + }); + } + return workspaces; + } + + async cleanup(options: ManagedWorkspaceCleanupOptions): Promise { + if (!Number.isFinite(options.retentionMs) || options.retentionMs < 0) { + throw new Error('Workspace retention must be a non-negative duration.'); + } + if (!Number.isInteger(options.keepNewest) || options.keepNewest < 0) { + throw new Error('Workspace keepNewest must be a non-negative integer.'); + } + const protectedPaths = new Set((options.protectedPaths ?? []).map((item) => path.resolve(item))); + const workspaces = await this.listManagedWorkspaces(); + const unprotectedNewest = [...workspaces] + .filter((workspace) => !protectedPaths.has(workspace.root)) + .sort((left, right) => right.lastUsedAt.getTime() - left.lastUsedAt.getTime()) + .slice(0, options.keepNewest); + const retainedNewestPaths = new Set(unprotectedNewest.map((workspace) => workspace.root)); + const cutoff = this.now().getTime() - options.retentionMs; + const removed: string[] = []; + const skipped: string[] = []; + + for (const workspace of workspaces) { + if ( + protectedPaths.has(workspace.root) || + retainedNewestPaths.has(workspace.root) || + workspace.lastUsedAt.getTime() > cutoff + ) { + skipped.push(workspace.root); + continue; + } + try { + if (await this.remove(workspace.root)) { + removed.push(workspace.root); + } else { + skipped.push(workspace.root); + } + } catch { + skipped.push(workspace.root); + } + } + + const pruned = await runGit(['worktree', 'prune', '--expire', 'now'], this.repoRoot, this.baseEnv); + if (!pruned.ok) { + throw new Error(pruned.output || 'Failed to prune git worktree metadata.'); + } + return { removed, skipped }; + } + private assertManagedWorkspacePath(workspacePath: string): string { const resolved = path.resolve(workspacePath); const root = path.resolve(this.worktreeRoot); diff --git a/app/gateway-api/test/orchestratorWorkspaceCleanup.test.ts b/app/gateway-api/test/orchestratorWorkspaceCleanup.test.ts index b54a1a04..8749f77c 100644 --- a/app/gateway-api/test/orchestratorWorkspaceCleanup.test.ts +++ b/app/gateway-api/test/orchestratorWorkspaceCleanup.test.ts @@ -1,15 +1,23 @@ +import path from 'node:path'; + import { describe, expect, it } from 'vitest'; import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js'; import type { ProcessManager } from '../src/orchestrator/processManager.js'; import type { GatewayProfileRecord, GatewayProfileRepository } from '../src/orchestrator/profileRepository.js'; -import type { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js'; +import { + DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST, + DEFAULT_MANAGED_WORKSPACE_RETENTION_MS, + type GitWorkspaceManager, + type ManagedWorkspaceCleanupOptions, +} from '../src/orchestrator/workspaceManager.js'; +const COMMIT_SHA = '0123456789abcdef0123456789abcdef01234567'; const oldUsage = '2025-01-01T00:00:00.000Z'; const makeProfile = ( profileName: string, - workspace: string, + workspace: string | undefined, overrides: Partial = {} ): GatewayProfileRecord => ({ profileName, @@ -20,7 +28,7 @@ const makeProfile = ( apiPort: 15_003, status: 'RUNNING', buildStatus: 'SUCCEEDED', - buildCommitSha: '0123456789abcdef0123456789abcdef01234567', + buildCommitSha: COMMIT_SHA, buildWorkspace: workspace, buildLastUsedAt: oldUsage, meta: {}, @@ -32,17 +40,10 @@ const makeProfile = ( const createHarness = ( profiles: GatewayProfileRecord[], processes: Awaited>, - workspaceExists = true + managedPaths: string[] ) => { - const removeCalls: string[] = []; - const clearedProfiles: string[][] = []; - - const repository = { - listProfiles: async () => profiles, - clearWorkspaceUsage: async (profileNames: string[]) => { - clearedProfiles.push(profileNames); - }, - } as unknown as GatewayProfileRepository; + const cleanupCalls: ManagedWorkspaceCleanupOptions[] = []; + const repository = { listProfiles: async () => profiles } as unknown as GatewayProfileRepository; const processManager: ProcessManager = { list: async () => processes, start: async () => {}, @@ -50,9 +51,16 @@ const createHarness = ( delete: async () => {}, }; const workspaceManager = { - remove: async (workspace: string) => { - removeCalls.push(workspace); - return workspaceExists; + listManagedWorkspaces: async () => + managedPaths.map((root) => ({ root, commitSha: path.basename(root), lastUsedAt: new Date(oldUsage) })), + workspacePathForCommit: (commitSha: string) => `/srv/sammo/worktrees/${commitSha}`, + cleanup: async (options: ManagedWorkspaceCleanupOptions) => { + cleanupCalls.push(options); + const protectedPaths = new Set(options.protectedPaths); + return { + removed: managedPaths.filter((workspace) => !protectedPaths.has(workspace)), + skipped: managedPaths.filter((workspace) => protectedPaths.has(workspace)), + }; }, } as unknown as GitWorkspaceManager; const orchestrator = new GatewayOrchestrator({ @@ -70,126 +78,67 @@ const createHarness = ( scheduleIntervalMs: 60_000, buildIntervalMs: 60_000, adminActionIntervalMs: 60_000, - now: () => new Date('2026-07-30T00:00:00.000Z'), }); - - return { orchestrator, removeCalls, clearedProfiles }; + return { orchestrator, cleanupCalls }; }; describe('GatewayOrchestrator workspace cleanup', () => { - it('skips a workspace referenced by any active process cwd', async () => { - const workspace = '/srv/sammo/worktrees/active'; + it('always protects every workspace currently selected by a profile', async () => { + const current = '/srv/sammo/worktrees/current'; + const stale = '/srv/sammo/worktrees/stale'; + const harness = createHarness([makeProfile('che:default', current)], [], [current, stale]); + + await expect(harness.orchestrator.cleanupStaleWorkspaces()).resolves.toEqual({ + removed: [stale], + skipped: [current], + }); + expect(harness.cleanupCalls[0]).toMatchObject({ + retentionMs: DEFAULT_MANAGED_WORKSPACE_RETENTION_MS, + keepNewest: DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST, + }); + }); + + it('protects the commit target of queued and running builds before the profile reference changes', async () => { + const target = `/srv/sammo/worktrees/${COMMIT_SHA}`; + const harness = createHarness([makeProfile('che:default', undefined, { buildStatus: 'QUEUED' })], [], [target]); + + await expect(harness.orchestrator.cleanupStaleWorkspaces()).resolves.toEqual({ + removed: [], + skipped: [target], + }); + }); + + it('protects an otherwise orphaned workspace referenced by any active process cwd or script', async () => { + const cwdWorkspace = '/srv/sammo/worktrees/cwd-orphan'; + const scriptWorkspace = '/srv/sammo/worktrees/script-orphan'; + const stale = '/srv/sammo/worktrees/stale'; const harness = createHarness( - [makeProfile('che:default', workspace)], + [], [ - { - name: 'sammo:che:default:frontend', - status: 'online', - cwd: `${workspace}/app/game-frontend`, - }, - ] + { name: 'custom-build', status: 'online', cwd: `${cwdWorkspace}/app/game-api` }, + { name: 'custom-worker', status: 'launching', script: `${scriptWorkspace}/dist/index.js` }, + { name: 'stopped-worker', status: 'stopped', cwd: `${stale}/app/game-api` }, + ], + [cwdWorkspace, scriptWorkspace, stale] ); await expect(harness.orchestrator.cleanupStaleWorkspaces()).resolves.toEqual({ - removed: [], - skipped: [workspace], + removed: [stale], + skipped: [cwdWorkspace, scriptWorkspace], }); - expect(harness.removeCalls).toEqual([]); - expect(harness.clearedProfiles).toEqual([]); }); - it('skips a workspace when only one profile process is active and cwd metadata is absent', async () => { - const workspace = '/srv/sammo/worktrees/partial'; - const harness = createHarness( - [makeProfile('che:default', workspace)], - [{ name: 'sammo:che:default:tournament-worker', status: 'launching' }] - ); - - await expect(harness.orchestrator.cleanupStaleWorkspaces()).resolves.toEqual({ - removed: [], - skipped: [workspace], - }); - expect(harness.removeCalls).toEqual([]); - }); - - it('skips a workspace referenced only by an active process script', async () => { - const workspace = '/srv/sammo/worktrees/script-reference'; - const harness = createHarness( - [makeProfile('che:default', workspace)], - [ - { - name: 'unregistered-worker-name', - status: 'online', - script: `${workspace}/app/game-api/dist/index.js`, - }, - ] - ); - - await expect(harness.orchestrator.cleanupStaleWorkspaces()).resolves.toEqual({ - removed: [], - skipped: [workspace], - }); - expect(harness.removeCalls).toEqual([]); - }); - - it('protects a shared workspace when a process for either profile is active', async () => { - const workspace = '/srv/sammo/worktrees/shared'; - const harness = createHarness( - [makeProfile('che:default', workspace), makeProfile('hwe:default', workspace)], - [{ name: 'sammo:hwe:default:game-api', status: 'stopping' }] - ); - - await expect(harness.orchestrator.cleanupStaleWorkspaces()).resolves.toEqual({ - removed: [], - skipped: [workspace], - }); - expect(harness.removeCalls).toEqual([]); - }); - - it('removes an old unreferenced workspace and clears every profile reference', async () => { - const workspace = '/srv/sammo/worktrees/stale'; - const harness = createHarness( - [makeProfile('che:default', workspace), makeProfile('hwe:default', workspace)], - [{ name: 'sammo:che:default:game-api', status: 'stopped', cwd: `${workspace}/app/game-api` }] - ); - - await expect(harness.orchestrator.cleanupStaleWorkspaces()).resolves.toEqual({ - removed: [workspace], - skipped: [], - }); - expect(harness.removeCalls).toEqual([workspace]); - expect(harness.clearedProfiles).toEqual([['che:default', 'hwe:default']]); - }); - - it('does not treat a sibling path with the same prefix as a workspace reference', async () => { + it('does not confuse sibling path prefixes with an active workspace reference', async () => { const workspace = '/srv/sammo/worktrees/commit-a'; const harness = createHarness( - [makeProfile('che:default', workspace)], - [ - { - name: 'unregistered-worker-name', - status: 'online', - cwd: '/srv/sammo/worktrees/commit-a-old/app/game-api', - }, - ] + [], + [{ name: 'custom-worker', status: 'online', cwd: `${workspace}-old/app/game-api` }], + [workspace] ); await expect(harness.orchestrator.cleanupStaleWorkspaces()).resolves.toEqual({ removed: [workspace], skipped: [], }); - expect(harness.removeCalls).toEqual([workspace]); - }); - - it('clears a stale database reference when the workspace is already missing', async () => { - const workspace = '/srv/sammo/worktrees/missing'; - const harness = createHarness([makeProfile('che:default', workspace)], [], false); - - await expect(harness.orchestrator.cleanupStaleWorkspaces()).resolves.toEqual({ - removed: [workspace], - skipped: [], - }); - expect(harness.removeCalls).toEqual([workspace]); - expect(harness.clearedProfiles).toEqual([['che:default']]); }); }); diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index be4a7d3d..643b8d68 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -39,7 +39,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, gatewaySchemaHead: '20260819000000_backfill_profile_release_source', - gameSchemaHead: '20260820001000_restore_united_turn_halt', + gameSchemaHead: '20260820002000_persist_official_game_index', }); }); diff --git a/app/gateway-api/test/workspaceManager.test.ts b/app/gateway-api/test/workspaceManager.test.ts index bf0a50f2..2fa37eb9 100644 --- a/app/gateway-api/test/workspaceManager.test.ts +++ b/app/gateway-api/test/workspaceManager.test.ts @@ -163,4 +163,46 @@ describe('GitWorkspaceManager source resolution', () => { ); expect(fs.existsSync(unregistered)).toBe(true); }); + + it('cleans only expired unprotected worktrees beyond the newest cache and preserves dirty work', async () => { + const fixture = createRepositoryFixture(); + const now = new Date('2026-08-20T12:00:00.000Z'); + const manager = new GitWorkspaceManager({ + repoRoot: fixture.checkout, + worktreeRoot: fixture.worktrees, + now: () => now, + }); + const workspaces = [await manager.prepare(fixture.firstCommit)]; + for (let index = 2; index <= 5; index += 1) { + fs.writeFileSync(path.join(fixture.source, 'version.txt'), `version ${index}\n`); + git(fixture.source, 'add', 'version.txt'); + git(fixture.source, 'commit', '-m', `version ${index}`); + git(fixture.source, 'push', 'origin', 'main'); + const commit = await manager.resolveCommit('BRANCH', 'main'); + workspaces.push(await manager.prepare(commit)); + } + const expired = new Date('2026-08-01T00:00:00.000Z'); + for (const workspace of workspaces) fs.utimesSync(workspace.root, expired, expired); + fs.writeFileSync(path.join(workspaces[1]!.root, 'preserve-me.txt'), 'uncommitted\n'); + fs.utimesSync(workspaces[1]!.root, expired, expired); + const recent = new Date('2026-08-20T11:00:00.000Z'); + fs.utimesSync(workspaces[4]!.root, recent, recent); + + const result = await manager.cleanup({ + protectedPaths: [workspaces[0]!.root], + retentionMs: 24 * 60 * 60 * 1_000, + keepNewest: 1, + }); + expect(result.removed).toHaveLength(2); + expect(result.removed).toEqual(expect.arrayContaining([workspaces[2]!.root, workspaces[3]!.root])); + expect(result.skipped).toHaveLength(3); + expect(result.skipped).toEqual( + expect.arrayContaining([workspaces[0]!.root, workspaces[1]!.root, workspaces[4]!.root]) + ); + expect(fs.existsSync(workspaces[0]!.root)).toBe(true); + expect(fs.existsSync(workspaces[1]!.root)).toBe(true); + expect(fs.existsSync(workspaces[2]!.root)).toBe(false); + expect(fs.existsSync(workspaces[3]!.root)).toBe(false); + expect(fs.existsSync(workspaces[4]!.root)).toBe(true); + }); }); diff --git a/app/release-controller/README.md b/app/release-controller/README.md index 69a0a115..d319933e 100644 --- a/app/release-controller/README.md +++ b/app/release-controller/README.md @@ -70,6 +70,13 @@ pnpm --filter @sammo-ts/release-controller status pnpm --filter @sammo-ts/release-controller run-once ``` +Daemon은 시작 시와 이후 24시간마다 commit worktree를 자동 정리합니다. 현재·이전 +Gateway release와 활성 PM2 process가 사용하는 경로는 항상 보호하고, 나머지는 +마지막 사용 후 24시간과 최신 2개 cache를 보장한 뒤 제거합니다. 변경이 있거나 Git +제거가 실패한 worktree는 raw directory 삭제로 우회하지 않고 다음 주기까지 +보존합니다. Profile worktree는 Gateway orchestrator가 같은 정책으로 별도 +관리합니다. + ## Controller self-upgrade 이 명령은 현재 daemon과 별개의 CLI process에서 실행됩니다. 대상 worktree를 diff --git a/app/release-controller/src/index.ts b/app/release-controller/src/index.ts index 19b43276..c0810564 100644 --- a/app/release-controller/src/index.ts +++ b/app/release-controller/src/index.ts @@ -7,7 +7,7 @@ import { } from '@sammo-ts/gateway-api'; import { resolveReleaseControllerConfig } from './config.js'; -import { GatewayReleaseController } from './releaseController.js'; +import { GatewayReleaseController, RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS } from './releaseController.js'; import { upgradeReleaseController } from './selfUpgrade.js'; export * from './config.js'; @@ -67,6 +67,7 @@ const main = async (): Promise => { } if (command !== 'daemon') throw new Error(`Unknown release-controller command: ${command}`); let stopping = false; + let nextWorkspaceCleanupAt = 0; const stop = async (): Promise => { if (stopping) return; stopping = true; @@ -75,6 +76,18 @@ const main = async (): Promise => { process.once('SIGINT', () => void stop()); process.once('SIGTERM', () => void stop()); while (!stopping) { + const now = Date.now(); + if (now >= nextWorkspaceCleanupAt) { + nextWorkspaceCleanupAt = now + RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS; + try { + const result = await controller.cleanupStaleWorkspaces(); + if (result.removed.length > 0) { + console.info(`[release-controller] removed ${result.removed.length} stale Gateway worktrees`); + } + } catch (error) { + console.error('[release-controller] workspace cleanup failed', error); + } + } await controller.runOnce(); await new Promise((resolve) => setTimeout(resolve, config.pollIntervalMs)); } diff --git a/app/release-controller/src/releaseController.ts b/app/release-controller/src/releaseController.ts index 6237c163..4e8f5bf0 100644 --- a/app/release-controller/src/releaseController.ts +++ b/app/release-controller/src/releaseController.ts @@ -6,6 +6,8 @@ import { assertReleaseComponents, buildTurboReleaseCommand, buildTurboReleaseTaskCommand, + DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST, + DEFAULT_MANAGED_WORKSPACE_RETENTION_MS, type BuildCommand, type BuildProgressEvent, type BuildRunner, @@ -27,6 +29,16 @@ const HEARTBEAT_INTERVAL_MS = 60_000; const CANCELLATION_POLL_INTERVAL_MS = 500; const PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'] as const; const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu; +export const RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1_000; + +const isRuntimeProcessActive = (status: string): boolean => + ['online', 'launching', 'stopping'].includes(status.toLowerCase()); + +const isPathInside = (candidate: string | undefined, root: string): boolean => { + if (!candidate) return false; + const relative = path.relative(path.resolve(root), path.resolve(candidate)); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +}; const managedPostgresPoolMax = (env: Record, roleVariable: string, fallback: number): string => String(resolvePostgresPoolMax(env[roleVariable] ?? env.POSTGRES_POOL_MAX, fallback)); @@ -132,6 +144,33 @@ export class GatewayReleaseController { private readonly fetchImpl: typeof fetch = fetch ) {} + async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> { + const [state, processes, workspaces] = await Promise.all([ + this.repository.getState(), + this.processManager.list(), + this.workspaceManager.listManagedWorkspaces(), + ]); + const protectedWorkspaces = new Set(); + if (state.activeWorkspace) protectedWorkspaces.add(path.resolve(state.activeWorkspace)); + if (state.previousWorkspace) protectedWorkspaces.add(path.resolve(state.previousWorkspace)); + const activeProcesses = processes.filter((process) => isRuntimeProcessActive(process.status)); + for (const workspace of workspaces) { + if ( + activeProcesses.some( + (process) => + isPathInside(process.cwd, workspace.root) || isPathInside(process.script, workspace.root) + ) + ) { + protectedWorkspaces.add(workspace.root); + } + } + return this.workspaceManager.cleanup({ + protectedPaths: [...protectedWorkspaces], + retentionMs: DEFAULT_MANAGED_WORKSPACE_RETENTION_MS, + keepNewest: DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST, + }); + } + private sanitizeLogMessage(message: string): string { let sanitized = stripVTControlCharacters(message); const sensitiveValues = new Set([ diff --git a/app/release-controller/test/releaseController.test.ts b/app/release-controller/test/releaseController.test.ts index aeb56f8e..a77e970b 100644 --- a/app/release-controller/test/releaseController.test.ts +++ b/app/release-controller/test/releaseController.test.ts @@ -2,14 +2,17 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import type { - BuildRunner, - GatewayReleaseOperationRecord, - GatewayReleaseRepository, - GatewayReleaseStateRecord, - GitWorkspaceManager, - ProcessDefinition, - ProcessManager, +import { + DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST, + DEFAULT_MANAGED_WORKSPACE_RETENTION_MS, + type BuildRunner, + type GatewayReleaseOperationRecord, + type GatewayReleaseRepository, + type GatewayReleaseStateRecord, + type GitWorkspaceManager, + type ManagedWorkspaceCleanupOptions, + type ProcessDefinition, + type ProcessManager, } from '@sammo-ts/gateway-api'; import { afterEach, describe, expect, it } from 'vitest'; @@ -178,6 +181,68 @@ it('rejects Gateway definitions before switching processes when Redis connection }); describe('GatewayReleaseController', () => { + it('protects active, rollback, and running-process worktrees while delegating bounded cleanup', async () => { + const active = '/srv/sammo/releases/active'; + const previous = '/srv/sammo/releases/previous'; + const controllerWorkspace = '/srv/sammo/releases/controller'; + const stale = '/srv/sammo/releases/stale'; + const managedPaths = [active, previous, controllerWorkspace, stale]; + const cleanupCalls: ManagedWorkspaceCleanupOptions[] = []; + const harness = createRepository(); + const workspaceManager = { + listManagedWorkspaces: async () => + managedPaths.map((root) => ({ + root, + commitSha: SHA, + lastUsedAt: new Date('2025-01-01T00:00:00.000Z'), + })), + cleanup: async (options: ManagedWorkspaceCleanupOptions) => { + cleanupCalls.push(options); + const protectedPaths = new Set(options.protectedPaths); + return { + removed: managedPaths.filter((workspace) => !protectedPaths.has(workspace)), + skipped: managedPaths.filter((workspace) => protectedPaths.has(workspace)), + }; + }, + } as unknown as GitWorkspaceManager; + const controller = new GatewayReleaseController( + { + ...harness.repository, + getState: async () => ({ + ...state, + activeWorkspace: active, + previousCommitSha: SHA, + previousWorkspace: previous, + }), + }, + workspaceManager, + { run: async () => ({ ok: true, exitCode: 0, output: '' }) }, + { + list: async () => [ + { + name: 'sammo:release-controller', + status: 'online', + cwd: `${controllerWorkspace}/app/release-controller`, + }, + { name: 'old-build', status: 'stopped', cwd: `${stale}/app/gateway-api` }, + ], + start: async () => {}, + stop: async () => {}, + delete: async () => {}, + }, + config + ); + + await expect(controller.cleanupStaleWorkspaces()).resolves.toEqual({ + removed: [stale], + skipped: [active, previous, controllerWorkspace], + }); + expect(cleanupCalls[0]).toMatchObject({ + retentionMs: DEFAULT_MANAGED_WORKSPACE_RETENTION_MS, + keepNewest: DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST, + }); + }); + it('builds, migrates, switches all gateway roles, verifies readiness, and publishes atomically', async () => { const workspace = await createReleaseWorkspace(); const harness = createRepository(); diff --git a/docs/release-operations.md b/docs/release-operations.md index 077b13a3..5e5fd86b 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -69,6 +69,29 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필 - migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이 유지됩니다. +## Commit worktree 자동 정리 + +Profile orchestrator와 Gateway release-controller는 서로 다른 worktree root를 +사용하지만 같은 보존 정책을 적용합니다. 각 daemon은 시작 시 한 번, 이후 24시간마다 +자신이 소유한 commit worktree를 점검합니다. + +- `GatewayProfile.buildWorkspace`, `RUNNING`/`QUEUED` profile 빌드 대상, + `GatewayReleaseState`의 active/previous workspace는 기간과 무관하게 보호합니다. +- 활성 PM2 process의 cwd 또는 script 아래에 있는 worktree도 보호합니다. 여기에는 + self-upgrade된 release-controller worktree도 포함됩니다. +- 보호 대상이 아닌 worktree는 마지막 prepare 이후 최소 24시간을 유예하고, 그중 + 최신 2개는 재시도 cache로 더 남깁니다. 나머지는 Git worktree로 제거하고 + `git worktree prune --expire now`로 사라진 metadata를 정리합니다. +- tracked 또는 untracked 변경이 있으면 자동 삭제하지 않습니다. Git 제거 실패를 + raw directory 삭제로 우회하지 않으며 다음 주기까지 보존합니다. +- 정리는 commit checkout과 재생성 가능한 build artifact만 대상으로 합니다. + Gateway/profile PostgreSQL, Redis, image, runtime data volume에는 접근하지 않습니다. + +따라서 하루 안에 매우 많은 commit을 연속 배포하면 유예 구간만큼 일시적으로 늘 수 +있지만, active/rollback/current profile 경로 외의 장기 누적은 다음 정리 주기에 +제거됩니다. Profile 관리자 API의 `admin.profiles.cleanupWorkspaces`는 같은 보호 +규칙을 사용하므로 진행 중인 build/operation이 있으면 전체 정리를 보류합니다. + ## Profile 배포 버전 업데이트 화면에서 profile의 branch 또는 commit을 선택합니다. Branch는 worker가 diff --git a/packages/infra/prisma/migrations/20260820002000_persist_official_game_index/migration.sql b/packages/infra/prisma/migrations/20260820002000_persist_official_game_index/migration.sql new file mode 100644 index 00000000..a2ca4ab0 --- /dev/null +++ b/packages/infra/prisma/migrations/20260820002000_persist_official_game_index/migration.sql @@ -0,0 +1,18 @@ +-- Ref stores server_cnt once at reset time because it is rendered on every main-page load. +-- Backfill the active world's equivalent read-model value while excluding cancelled and +-- unfinished history rows from the official sequence. +UPDATE "world_state" AS ws +SET "meta" = jsonb_set( + COALESCE(ws."meta", '{}'::jsonb), + '{gameIdx}', + to_jsonb(( + SELECT COUNT(*)::integer + 1 + FROM "ng_games" AS history + WHERE history."status" = 'COMPLETED' + AND ( + ws."meta"->>'serverId' IS NULL + OR history."server_id" <> ws."meta"->>'serverId' + ) + )), + true +); diff --git a/packages/logic/src/logging/entries.ts b/packages/logic/src/logging/entries.ts index 5747794b..4598da04 100644 --- a/packages/logic/src/logging/entries.ts +++ b/packages/logic/src/logging/entries.ts @@ -47,8 +47,9 @@ export const finalizeLogEntry = (entry: LogEntryDraft, context: LogContext): Log if (entry.meta !== undefined) { record.meta = entry.meta; } - if (context.at !== undefined) { - record.createdAt = context.at; + const createdAt = entry.occurredAt ?? context.at; + if (createdAt !== undefined) { + record.createdAt = createdAt; } return record; diff --git a/packages/logic/src/logging/types.ts b/packages/logic/src/logging/types.ts index 1d7a2cc3..fbd3afa1 100644 --- a/packages/logic/src/logging/types.ts +++ b/packages/logic/src/logging/types.ts @@ -31,6 +31,8 @@ export interface LogEntryDraft { /** 월 경계 전 action처럼 flush 시점과 다른 달에 귀속되는 로그의 명시적 날짜. */ year?: number; month?: number; + /** 로그를 만든 논리 게임 시각. 생략하면 flush context의 시각을 사용한다. */ + occurredAt?: Date; } export interface LogEntryRecord { diff --git a/packages/logic/src/rewards/legacyUniqueItemPool.ts b/packages/logic/src/rewards/legacyUniqueItemPool.ts index b6f866d9..00835f14 100644 --- a/packages/logic/src/rewards/legacyUniqueItemPool.ts +++ b/packages/logic/src/rewards/legacyUniqueItemPool.ts @@ -1,5 +1,6 @@ +import { createItemModuleRegistry, ItemLoader, ITEM_KEYS, loadItemModules } from '@sammo-ts/logic/items/index.js'; import type { ItemModule } from '@sammo-ts/logic/items/types.js'; -import type { UniqueItemPool } from './uniqueLottery.js'; +import { resolveUniqueConfig, type UniqueItemPool, type UniqueLotteryConfig } from './uniqueLottery.js'; const LEGACY_UNIQUE_ITEM_KEYS: Readonly> = { horse: [ @@ -125,3 +126,38 @@ export const buildLegacyDefaultUniqueItemPool = (itemRegistry: Map | null = null; + +const cloneUniqueItemPool = (pool: UniqueItemPool): UniqueItemPool => + Object.fromEntries(Object.entries(pool).map(([slot, entries]) => [slot, { ...entries }])); + +export const loadLegacyDefaultUniqueItemPool = async (loader?: ItemLoader): Promise => { + if (loader) { + const modules = await loadItemModules([...ITEM_KEYS], loader); + return buildLegacyDefaultUniqueItemPool(createItemModuleRegistry(modules)); + } + + legacyDefaultUniqueItemPoolPromise ??= loadItemModules([...ITEM_KEYS], new ItemLoader()).then((modules) => + buildLegacyDefaultUniqueItemPool(createItemModuleRegistry(modules)) + ); + return cloneUniqueItemPool(await legacyDefaultUniqueItemPoolPromise); +}; + +/** + * Ref의 GameConst 기본값은 시나리오가 allItems를 덮어쓰지 않아도 항상 존재합니다. + * 오래된 Core snapshot의 생략값/문자열 빈 객체도 같은 기본 풀로 해석합니다. + */ +export const resolveLegacyCompatibleUniqueConfig = async ( + configConst: Record, + loader?: ItemLoader +): Promise => { + const config = resolveUniqueConfig(configConst); + if (Object.keys(config.allItems).length > 0) { + return config; + } + return { + ...config, + allItems: await loadLegacyDefaultUniqueItemPool(loader), + }; +}; diff --git a/packages/logic/test/legacyUniqueItemPool.test.ts b/packages/logic/test/legacyUniqueItemPool.test.ts new file mode 100644 index 00000000..6d339e3a --- /dev/null +++ b/packages/logic/test/legacyUniqueItemPool.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveLegacyCompatibleUniqueConfig } from '../src/rewards/legacyUniqueItemPool.js'; + +describe('legacy-compatible unique item pool', () => { + it.each([undefined, {}, '{}'] as const)('restores the Ref default pool when allItems is %j', async (allItems) => { + const config = await resolveLegacyCompatibleUniqueConfig(allItems === undefined ? {} : { allItems }); + + expect(Object.keys(config.allItems)).toEqual(['horse', 'weapon', 'book', 'item']); + expect(config.allItems.weapon?.che_무기_12_칠성검).toBe(2); + expect(config.allItems.item?.che_의술_청낭서).toBe(1); + expect(config.allItems.weapon?.che_무기_01_단도).toBeUndefined(); + }); + + it('preserves an explicit scenario pool, including its counts', async () => { + const config = await resolveLegacyCompatibleUniqueConfig({ + allItems: { + weapon: { + che_무기_12_칠성검: 7, + }, + }, + }); + + expect(config.allItems).toEqual({ + weapon: { + che_무기_12_칠성검: 7, + }, + }); + }); +}); diff --git a/packages/logic/test/loggingEntries.test.ts b/packages/logic/test/loggingEntries.test.ts index f7061410..7dd3e543 100644 --- a/packages/logic/test/loggingEntries.test.ts +++ b/packages/logic/test/loggingEntries.test.ts @@ -22,4 +22,22 @@ describe('finalizeLogEntry', () => { text: '●193년 12월:이전 달 사건', }); }); + + it('keeps an explicit occurrence time instead of replacing it with the flush time', () => { + const occurredAt = new Date('0200-01-01T00:37:43.000Z'); + const flushAt = new Date('0200-01-01T00:40:00.000Z'); + + expect( + finalizeLogEntry( + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + text: '상업 투자를 실행했습니다.', + generalId: 1, + occurredAt, + }, + { year: 200, month: 1, at: flushAt } + )?.createdAt + ).toEqual(occurredAt); + }); }); diff --git a/release-manifest.json b/release-manifest.json index 4cac89dc..de7f2e25 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260819000000_backfill_profile_release_source", - "gameSchemaHead": "20260820001000_restore_united_turn_halt", + "gameSchemaHead": "20260820002000_persist_official_game_index", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] } diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json index 29a6a649..6553d1f8 100644 --- a/resources/turn-commands/default.json +++ b/resources/turn-commands/default.json @@ -33,6 +33,7 @@ "che_징병", "che_모병", "che_소집해제", + "che_첩보", "che_군량매매", "che_물자조달", "che_증여", diff --git a/tools/frontend-legacy-parity/inheritance-management.spec.ts b/tools/frontend-legacy-parity/inheritance-management.spec.ts index 3758c333..7b50b62a 100644 --- a/tools/frontend-legacy-parity/inheritance-management.spec.ts +++ b/tools/frontend-legacy-parity/inheritance-management.spec.ts @@ -81,11 +81,33 @@ const statusFixture = { resetLevels: { resetSpecialWar: 0, resetTurnTime: 0 }, availableSpecialWar: [{ key: 'che_선봉', name: '선봉', info: '공격에 유리합니다.' }], availableUnique: [ + { + key: 'che_명마_07_백마', + name: '백마(+7)', + rawName: '백마', + info: '기동력을 올려주는 유니크 명마입니다.', + slot: 'horse', + }, { key: 'che_무기_12_칠성검', name: '칠성검(+12)', rawName: '칠성검', info: '무력을 올려주는 유니크 무기입니다.', + slot: 'weapon', + }, + { + key: 'che_서적_07_논어', + name: '논어(+7)', + rawName: '논어', + info: '지력을 올려주는 유니크 서적입니다.', + slot: 'book', + }, + { + key: 'che_보물_도기', + name: '도기', + rawName: '도기', + info: '전투를 돕는 유니크 도구입니다.', + slot: 'item', }, ], availableTargetGenerals: [{ id: 8, name: '조조' }], @@ -98,6 +120,7 @@ const statusFixture = { const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => { let buffMutationCount = 0; let resetTurnMutationCount = 0; + const uniqueAuctionRequests: unknown[] = []; await installImages(page); await page.addInitScript(() => { window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token'); @@ -105,6 +128,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) }); await page.route('**/che/api/trpc/**', async (route) => { const names = operations(route); + const requestBody: unknown = route.request().postData() ? route.request().postDataJSON() : null; if (options.failBuff && names.includes('inherit.buyHiddenBuff')) { await route.fulfill({ status: 500, @@ -145,6 +169,10 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) resetTurnMutationCount += 1; return response({ ok: true, nextTurnTimeBase: 302.5143852464758, nextTurnTimeLabel: '00:05' }); } + if (name === 'inherit.openUniqueAuction') { + uniqueAuctionRequests.push(requestBody); + return response({ ok: true, auctionId: 31, closeAt: '2026-07-27T00:00:00.000Z' }); + } throw new Error(`Unhandled inheritance fixture operation: ${name}`); }); await route.fulfill({ @@ -156,6 +184,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) return { buffMutationCount: () => buffMutationCount, resetTurnMutationCount: () => resetTurnMutationCount, + uniqueAuctionRequests, }; }; @@ -184,7 +213,21 @@ test.describe('inheritance management legacy parity', () => { await page.setViewportSize({ width: 1280, height: 900 }); await page.goto(gameUrl); await expect(page.locator('#container')).toBeVisible(); - await expect(page.locator('#specific-unique')).toHaveValue('che_무기_12_칠성검'); + await expect(page.locator('#specific-unique')).toHaveValue('che_명마_07_백마'); + await expect(page.locator('#specific-unique optgroup')).toHaveCount(4); + expect( + await page.locator('#specific-unique optgroup').evaluateAll((groups) => + groups.map((group) => ({ + label: group.getAttribute('label'), + values: [...group.querySelectorAll('option')].map((option) => option.value), + })) + ) + ).toEqual([ + { label: '명마', values: ['che_명마_07_백마'] }, + { label: '무기', values: ['che_무기_12_칠성검'] }, + { label: '서적', values: ['che_서적_07_논어'] }, + { label: '도구', values: ['che_보물_도기'] }, + ]); const desktop = await page.evaluate(() => { const rect = (selector: string) => { @@ -290,6 +333,28 @@ test.describe('inheritance management legacy parity', () => { await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000'); }); + test('selects a Ref default unique and starts its auction from the inheritance page', async ({ page }) => { + const fixture = await installFixture(page); + await page.goto(gameUrl); + + await page.locator('#specific-unique').selectOption('che_서적_07_논어'); + await page.locator('#specific-unique-amount').fill('6000'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('6000 포인트로 논어(+7)를 입찰하겠습니까?'); + await dialog.accept(); + }); + await page + .locator('.shop-item') + .filter({ has: page.locator('#specific-unique') }) + .getByRole('button', { name: '경매 시작' }) + .click(); + + await expect.poll(() => fixture.uniqueAuctionRequests.length).toBe(1); + expect(JSON.stringify(fixture.uniqueAuctionRequests[0])).toContain('che_서적_07_논어'); + expect(JSON.stringify(fixture.uniqueAuctionRequests[0])).toContain('6000'); + await expect(page.locator('.notice.success')).toHaveText('성공했습니다. 경매장을 확인해주세요.'); + }); + test('keeps controls usable and renders an API mutation error', async ({ page }) => { await installFixture(page, { failBuff: true }); page.on('dialog', (dialog) => dialog.accept()); diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index e9543045..4f3f7ec2 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -888,8 +888,10 @@ export const runCoreTurnCommandTrace = async ( id: index + 1, scope: log.scope, category: log.category, - generalId: log.generalId ?? (log.scope === 'GENERAL' ? actor.id : undefined), - nationId: log.nationId ?? (log.scope === 'NATION' ? actor.nationId : undefined), + // Keep the product draft unchanged. Inferring an owner here hid + // GENERAL logs that finalizeLogEntry would reject in production. + generalId: log.generalId, + nationId: log.nationId, year: state.currentYear, month: state.currentMonth, text: log.text, diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index 13232b65..e111fa96 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -26,15 +26,8 @@ import { resolveRedisConfigFromEnv, GamePrisma, } from '@sammo-ts/infra'; -import { - buildNeutralResourceAuctionPlan, - ItemLoader, - ITEM_KEYS, -} from '@sammo-ts/logic'; -import { - createItemInventoryFromSlots, - serializeItemInventory, -} from '@sammo-ts/logic/items/inventory.js'; +import { buildNeutralResourceAuctionPlan, ItemLoader, ITEM_KEYS } from '@sammo-ts/logic'; +import { createItemInventoryFromSlots, serializeItemInventory } from '@sammo-ts/logic/items/inventory.js'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); @@ -301,6 +294,7 @@ describe('auction integration flow', () => { databaseUrl: resolvePostgresConfigFromEnv({ schema: 'che' }).url, adminUser: bootstrap.user, installOptions: { + serverId: 'auction-integration-908', turnTermMinutes: 1, sync: false, fiction: 0, @@ -676,6 +670,69 @@ describe('auction integration flow', () => { turnDaemonLoop = turnDaemon.lifecycle.start(); await sleep(500); + const directTransport = new DatabaseTurnDaemonTransport(prisma, 30_000); + await expect( + directTransport.requestCommand( + { + type: 'auctionBid', + auctionId: auction.id, + generalId: validBidder.generalId, + amount: 400, + }, + 30_000 + ) + ).resolves.toMatchObject({ + type: 'auctionBid', + ok: false, + reason: '이미 다른 유니크를 가지고 있습니다.', + }); + + const siblingCloseAt = new Date(turnDaemon.world.getGameNow(new Date()).getTime() + 10 * 60_000); + const siblingAuction = await prisma.auction.create({ + data: { + type: 'UNIQUE_ITEM', + targetCode: uniquePair.keyB, + hostGeneralId: 0, + hostName: '시스템', + detail: { + startBidAmount: 200, + isReverse: false, + availableLatestBidCloseDate: siblingCloseAt.toISOString(), + }, + status: 'OPEN', + closeAt: siblingCloseAt, + closeTick: BigInt(turnDaemon.world.dateToGameTick(siblingCloseAt)), + }, + }); + await prisma.auctionBid.create({ + data: { + auctionId: siblingAuction.id, + generalId: spareBidder.generalId, + amount: 350, + eventId: `same-slot-race-${siblingAuction.id}`, + eventAt: turnDaemon.world.getGameNow(new Date()), + }, + }); + await expect( + directTransport.requestCommand( + { + type: 'auctionBid', + auctionId: auction.id, + generalId: spareBidder.generalId, + amount: 400, + }, + 30_000 + ) + ).resolves.toMatchObject({ + type: 'auctionBid', + ok: false, + reason: '1순위 입찰자인 경매중에 같은 부위가 있습니다.', + }); + await prisma.auction.update({ + where: { id: siblingAuction.id }, + data: { status: 'CANCELED', finishedAt: new Date() }, + }); + const finalizeAt = new Date(turnDaemon!.world.getGameNow(new Date()).getTime() - 1000); const finalizeTick = turnDaemon!.world.dateToGameTick(finalizeAt); await prisma.auction.update({ @@ -684,7 +741,6 @@ describe('auction integration flow', () => { }); await redis.zAdd(keys.timerKey, [{ score: finalizeTick, value: String(auction.id) }]); - const transport = new DatabaseTurnDaemonTransport(prisma, 30_000); await prisma.$executeRaw( GamePrisma.sql` UPDATE auction @@ -694,15 +750,16 @@ describe('auction integration flow', () => { WHERE id = ${auction.id} ` ); - const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); + const result = await directTransport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); expect(result).toMatchObject({ type: 'auctionFinalize', ok: false }); const reopened = await prisma.auction.findUnique({ where: { id: auction.id }, select: { status: true, closeAt: true }, }); - expect(reopened?.status).toBe('OPEN'); - expect(reopened?.closeAt.getTime()).toBeGreaterThan(finalizeAt.getTime()); + expect(reopened).not.toBeNull(); + expect(reopened!.status).toBe('OPEN'); + expect(reopened!.closeAt.getTime() - finalizeAt.getTime()).toBe(24 * 60_000); }, 60_000); it('unique auction: two bidders extend until limit, then winner gets item', async () => { @@ -1013,4 +1070,154 @@ describe('auction integration flow', () => { meta: expect.objectContaining({ neutralAuctionRegistrationKey: '180-02' }), }); }, 60_000); + + it('starts from inheritance, accepts another user bid, and awards a default-pool unique', async () => { + if (!gameConnector || !redisConnector || !gameServer) { + throw new Error('runtime not ready'); + } + const prisma = gameConnector.prisma; + const redis = redisConnector.client; + const [host, bidder] = userSessions; + if (!host || !bidder) { + throw new Error('not enough bidders'); + } + + if (turnDaemon) { + await turnDaemon.lifecycle.stop('integration-test'); + await turnDaemon.close(); + await turnDaemonLoop; + } + + const uniquePair = await findUniqueItemPair(); + const slotField = resolveSlotField(uniquePair.slot); + if (!slotField) { + throw new Error('unsupported item slot'); + } + const state = await prisma.worldState.findFirstOrThrow(); + const config = + state.config && typeof state.config === 'object' && !Array.isArray(state.config) ? state.config : {}; + const configConst = + config.const && typeof config.const === 'object' && !Array.isArray(config.const) ? config.const : {}; + await prisma.worldState.update({ + where: { id: state.id }, + data: { + currentYear: 180, + currentMonth: 4, + config: { + ...config, + const: { + ...configConst, + // 표준 Ref 시나리오는 GameConst 기본값을 상속한다. 오래된 + // Core snapshot의 문자열 빈 객체도 같은 입력으로 검증한다. + allItems: '{}', + }, + }, + }, + }); + await prisma.auction.updateMany({ + where: { type: 'UNIQUE_ITEM', status: { in: ['OPEN', 'FINALIZING'] } }, + data: { status: 'CANCELED', finishedAt: new Date() }, + }); + + for (const session of [host, bidder]) { + const row = await prisma.general.findUniqueOrThrow({ + where: { id: session.generalId }, + select: { meta: true }, + }); + const meta = row.meta && typeof row.meta === 'object' && !Array.isArray(row.meta) ? row.meta : {}; + await prisma.general.update({ + where: { id: session.generalId }, + data: { + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + meta: { + ...meta, + itemInventory: serializeItemInventory( + createItemInventoryFromSlots({ horse: null, weapon: null, book: null, item: null }) + ), + }, + }, + }); + await prisma.inheritancePoint.upsert({ + where: { userId_key: { userId: session.userId, key: 'previous' } }, + update: { value: 100_000 }, + create: { userId: session.userId, key: 'previous', value: 100_000 }, + }); + } + await prisma.general.updateMany({ + where: { [slotField]: uniquePair.keyA } as GamePrisma.GeneralWhereInput, + data: { [slotField]: 'None' } as GamePrisma.GeneralUpdateManyMutationInput, + }); + + const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; + const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + turnDaemon = await createTurnDaemonRuntime({ + profile: 'che', + profileName: 'che:908', + databaseUrl: gameDatabaseUrl, + gatewayDatabaseUrl, + }); + turnDaemonLoop = turnDaemon.lifecycle.start(); + await sleep(500); + + const hostClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: host.accessToken }); + const bidderClient = createGameClient(gameUrl, gameServer.config.trpcPath, { value: bidder.accessToken }); + const inheritStatus = await hostClient.inherit.getStatus.query(); + expect(inheritStatus.availableUnique.length).toBeGreaterThan(80); + expect(inheritStatus.availableUnique).toEqual( + expect.arrayContaining([expect.objectContaining({ key: uniquePair.keyA })]) + ); + + const opened = await hostClient.inherit.openUniqueAuction.mutate({ + itemId: uniquePair.keyA, + amount: 5_000, + }); + const auction = await prisma.auction.findUniqueOrThrow({ where: { id: opened.auctionId } }); + expect(auction).toMatchObject({ + type: 'UNIQUE_ITEM', + targetCode: uniquePair.keyA, + hostGeneralId: host.generalId, + status: 'OPEN', + }); + const timerKeys = buildAuctionTimerKeys(gameServer.config.profileName); + await expect(redis.zScore(timerKeys.timerKey, String(auction.id))).resolves.not.toBeNull(); + + await bidderClient.auction.bidUnique.mutate({ auctionId: auction.id, amount: 5_100 }); + await expect( + prisma.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: host.userId, key: 'previous' } }, + }) + ).resolves.toMatchObject({ value: 100_000 }); + + const finalizeAt = new Date(turnDaemon.world.getGameNow(new Date()).getTime() - 1_000); + const finalizeTick = turnDaemon.world.dateToGameTick(finalizeAt); + await prisma.auction.update({ + where: { id: auction.id }, + data: { + closeAt: finalizeAt, + closeTick: BigInt(finalizeTick), + status: 'FINALIZING', + finalizingAt: new Date(), + }, + }); + const transport = new DatabaseTurnDaemonTransport(prisma, 30_000); + const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); + expect(result).toMatchObject({ type: 'auctionFinalize', ok: true }); + + await expect(prisma.auction.findUniqueOrThrow({ where: { id: auction.id } })).resolves.toMatchObject({ + status: 'FINISHED', + }); + const winner = await prisma.general.findUniqueOrThrow({ + where: { id: bidder.generalId }, + select: { weaponCode: true, bookCode: true, horseCode: true, itemCode: true }, + }); + expect(Object.values(winner)).toContain(uniquePair.keyA); + await expect( + prisma.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: bidder.userId, key: 'previous' } }, + }) + ).resolves.toMatchObject({ value: 94_900 }); + }, 60_000); });