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 ae08f39c..8043a2a6 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -2273,9 +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({ - ...createGeneralActionLog(general.id, failureText), - }); + options.world.pushLog(createGeneralActionLog(general.id, failureText), general.turnTime); } return { ok: false, reason: failureText }; } @@ -2352,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: '가까운 아국 도시가 없습니다.' }; } @@ -2436,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/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-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 859949a7..53c68c97 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -1287,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 { @@ -1325,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/e2e/nationCityOfficeIntegration.spec.ts b/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts new file mode 100644 index 00000000..ed3dfc5f --- /dev/null +++ b/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts @@ -0,0 +1,420 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; + +type Role = 'head' | 'member'; +type AppointmentInput = { destGeneralId: number; destCityId: number; officerLevel: number }; +type FixtureState = { + role: Role; + appointed: boolean; + secretForbidden?: boolean; + appointmentInputs: AppointmentInput[]; +}; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string, code = 'BAD_REQUEST') => ({ + error: { message, code: -32000, data: { code, httpStatus: code === 'FORBIDDEN' ? 403 : 400, path } }, +}); +const operations = (route: Route): string[] => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); +const requestInput = (route: Route, index: number): Record => { + const body: unknown = route.request().postData() ? route.request().postDataJSON() : {}; + const record = body && typeof body === 'object' ? (body as Record) : {}; + const raw = record[String(index)] ?? record; + const payload = raw && typeof raw === 'object' ? (raw as Record) : {}; + const input = payload.input && typeof payload.input === 'object' ? (payload.input as Record) : {}; + const json = payload.json ?? input.json ?? payload; + return json && typeof json === 'object' ? (json as Record) : {}; +}; + +const cities = [ + { + id: 1, + name: '허창', + level: 7, + region: 2, + population: 99_000, + populationMax: 100_000, + agriculture: 9_500, + agricultureMax: 10_000, + commerce: 8_000, + commerceMax: 10_000, + security: 8_000, + securityMax: 10_000, + trust: 80, + trade: 100, + defence: 4_500, + defenceMax: 5_000, + wall: 4_500, + wallMax: 5_000, + supplyState: 1, + frontState: 0, + incomes: { gold: 1000, rice: 900, wall: 800 }, + }, + { + id: 2, + name: '낙양', + level: 6, + region: 2, + population: 60_000, + populationMax: 100_000, + agriculture: 5_000, + agricultureMax: 10_000, + commerce: 5_000, + commerceMax: 10_000, + security: 5_000, + securityMax: 10_000, + trust: 70, + trade: 90, + defence: 2_500, + defenceMax: 5_000, + wall: 2_500, + wallMax: 5_000, + supplyState: 1, + frontState: 0, + incomes: { gold: 800, rice: 700, wall: 600 }, + }, +] as const; + +const overviewFixture = (state: FixtureState) => ({ + me: { id: state.role === 'head' ? 20 : 21, officerLevel: state.role === 'head' ? 5 : 1 }, + nation: { + id: 1, + name: '위', + color: '#008000', + level: 3, + typeCode: 'che_법가', + capitalCityId: 1, + rate: 20, + }, + chiefStatMin: 65, + cities: cities.map((city) => ({ + ...city, + officers: { + 4: state.appointed + ? { id: 21, name: '장료', npcState: 0, officerLevel: 4, cityId: 1, cityName: '허창' } + : null, + 3: null, + 2: null, + }, + })), + generals: [ + { + id: 1, + name: '조조', + npcState: 0, + officerLevel: 12, + cityId: 1, + officerCity: 0, + stats: { leadership: 90, strength: 80, intelligence: 90 }, + }, + { + id: 20, + name: '순욱', + npcState: 0, + officerLevel: 5, + cityId: 1, + officerCity: 0, + stats: { leadership: 75, strength: 70, intelligence: 90 }, + }, + { + id: 21, + name: '장료', + npcState: 0, + officerLevel: state.appointed ? 4 : 1, + cityId: 1, + officerCity: state.appointed ? 1 : 0, + stats: { leadership: 80, strength: 70, intelligence: 50 }, + }, + { + id: 22, + name: '조홍', + npcState: 2, + officerLevel: 1, + cityId: 2, + officerCity: 0, + stats: { leadership: 60, strength: 65, intelligence: 40 }, + }, + ], +}); + +const secretGeneral = (id: number, name: string, cityId: number, overrides: Record = {}) => ({ + id, + name, + npcState: 0, + injury: 0, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + leadershipBonus: 0, + experienceLevel: 9, + troopId: 0, + troopName: null, + gold: 1000, + rice: 2000, + cityId, + cityName: cityId === 1 ? '허창' : '낙양', + defenceTrain: 90, + defenceTrainText: '☆', + crewTypeId: 1, + crewTypeName: '보병', + crew: 300, + train: 90, + atmos: 90, + killTurn: 7, + turnTime: '2026-01-01T01:02:00.000Z', + reservedCommands: ['농지 개간', '훈련'], + ...overrides, +}); + +const secretFixture = () => ({ + nation: { id: 1, name: '위', color: '#008000', level: 3 }, + viewer: { generalId: 20, permission: 1 }, + summary: { + gold: 4000, + rice: 8000, + crew: 1200, + generalCount: 4, + averageGold: 1000, + averageRice: 2000, + readiness: { + 90: { crew: 1200, generals: 4 }, + 80: { crew: 1200, generals: 4 }, + 60: { crew: 1200, generals: 4 }, + }, + }, + generals: [ + secretGeneral(1, '조조', 1, { leadershipBonus: 6 }), + secretGeneral(20, '순욱', 1, { leadershipBonus: 3 }), + secretGeneral(21, '장료', 1, { + stats: { leadership: 80, strength: 70, intelligence: 50 }, + }), + secretGeneral(22, '조홍', 2, { npcState: 2, reservedCommands: [] }), + ], +}); + +const personnelGeneral = (id: number, name: string, officerLevel: number, overrides: Record = {}) => ({ + id, + name, + npcState: 0, + officerLevel, + cityId: 1, + cityName: '허창', + troopId: 0, + troopName: null, + picture: null, + imageServer: 0, + officerCity: officerLevel >= 2 && officerLevel <= 4 ? 1 : 0, + officerCityName: officerLevel >= 2 && officerLevel <= 4 ? '허창' : null, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + experience: 100, + dedication: 200, + injury: 0, + gold: 1000, + rice: 1000, + crew: 100, + personality: null, + specialDomestic: null, + specialWar: null, + belong: 10, + permission: 'normal', + ...overrides, +}); + +const personnelFixture = (state: FixtureState) => { + const allGenerals = [ + personnelGeneral(1, '조조', 12), + personnelGeneral(20, '순욱', 5, { stats: { leadership: 75, strength: 70, intelligence: 90 } }), + personnelGeneral(21, '장료', state.appointed ? 4 : 1, { + stats: { leadership: 80, strength: 70, intelligence: 50 }, + }), + personnelGeneral(22, '조홍', 1, { + npcState: 2, + cityId: 2, + cityName: '낙양', + stats: { leadership: 60, strength: 65, intelligence: 40 }, + }), + ]; + const canManage = state.role === 'head'; + return { + me: { + id: canManage ? 20 : 21, + officerLevel: canManage ? 5 : 1, + canManage, + canChangePermissions: false, + canKick: canManage, + }, + nation: { + id: 1, + name: '위', + color: '#008000', + level: 3, + typeCode: 'che_법가', + capitalCityId: 1, + chiefSet: 0, + }, + chiefStatMin: 65, + generals: canManage ? allGenerals : [], + chiefAssignments: { 12: allGenerals[0], 5: allGenerals[1] }, + cityAssignments: cities.map((city) => ({ + id: city.id, + name: city.name, + level: city.level, + region: city.region, + officerSet: city.id === 1 && state.appointed ? 1 << 4 : 0, + officers: { + 4: city.id === 1 && state.appointed ? allGenerals[2] : null, + 3: null, + 2: null, + }, + })), + awards: { tigers: [], eagles: [] }, + permissionCandidates: { ambassadors: [], auditors: [] }, + }; +}; + +const install = async (page: Page, state: FixtureState): Promise => { + await page.addInitScript((profile) => { + localStorage.setItem('sammo-game-token', 'ga_city_office'); + localStorage.setItem('sammo-game-profile', profile); + }, gameProfile); + await page.route(gameTrpcRoute, async (route) => { + const result = operations(route).map((operation, index) => { + if (operation === 'auth.status') return response({ ok: true }); + if (operation === 'lobby.info') return response({ myGeneral: { id: 20, name: '순욱' } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'nation.getCityOverview') return response(overviewFixture(state)); + if (operation === 'nation.getSecretGeneralList') { + return state.secretForbidden + ? errorResponse( + operation, + '권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.', + 'FORBIDDEN' + ) + : response(secretFixture()); + } + if (operation === 'nation.getPersonnelInfo') return response(personnelFixture(state)); + if (operation === 'nation.appoint') { + const input = requestInput(route, index); + state.appointmentInputs.push({ + destGeneralId: Number(input.destGeneralId), + destCityId: Number(input.destCityId), + officerLevel: Number(input.officerLevel), + }); + state.appointed = true; + return response({ ok: true }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(result) }); + }); +}; + +test('암행부 행을 도시별로 나누고 수뇌의 인사부 즉시 임명을 반영한다', async ({ page }, testInfo) => { + const state: FixtureState = { role: 'head', appointed: false, appointmentInputs: [] }; + await install(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('nation/cities'); + + await expect(page.locator('.nation-cities-page')).toBeVisible(); + await expect(page.locator('.city-user-table')).toHaveCount(0); + await expect(page.getByRole('button', { name: '인사부 연동' })).toHaveCount(0); + + await page.getByRole('button', { name: '암행부 연동' }).click(); + await expect(page.locator('.city-user-table')).toHaveCount(2); + await expect(page.locator('.city[data-city-id="1"] .city-user-table tr[data-general-id="21"]')).toContainText( + '장료' + ); + await expect(page.locator('.city[data-city-id="2"] .city-user-table tr[data-general-id="22"]')).toContainText( + '조홍' + ); + await expect(page.locator('.city[data-city-id="2"] .city-user-table tr[data-general-id="21"]')).toHaveCount(0); + await expect(page.locator('.city[data-city-id="1"] tr[data-general-id="21"] .command-attention')).toHaveText( + '농지 개간' + ); + + const integratedBox = await page.locator('.city[data-city-id="1"] .city-user-table').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { width: rect.width, borderCollapse: style.borderCollapse, fontSize: style.fontSize }; + }); + expect(integratedBox).toEqual({ width: 941, borderCollapse: 'collapse', fontSize: '14px' }); + + await page.getByRole('button', { name: '인사부 연동' }).click(); + const ordinaryRow = page.locator('.city[data-city-id="1"] tr[data-general-id="21"]'); + await expect(ordinaryRow.locator('.appointment-button')).toHaveCount(3); + await expect(ordinaryRow.locator('.mode-4')).toBeEnabled(); + await expect(ordinaryRow.locator('.mode-3')).toBeDisabled(); + await expect(ordinaryRow.locator('.mode-2')).toBeEnabled(); + await expect(page.locator('tr[data-general-id="1"] .appointment-button')).toHaveCount(0); + + const disabledStyle = await ordinaryRow.locator('.mode-3').evaluate((button) => { + const style = getComputedStyle(button); + return { borderTopWidth: style.borderTopWidth, backgroundColor: style.backgroundColor }; + }); + expect(disabledStyle).toEqual({ borderTopWidth: '0px', backgroundColor: 'rgba(0, 0, 0, 0)' }); + const appointButton = page.getByRole('button', { name: '장료을(를) 허창 태수로 임명' }); + await appointButton.hover(); + expect(await appointButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer'); + await appointButton.focus(); + await expect(appointButton).toBeFocused(); + + await page.screenshot({ path: testInfo.outputPath('nation-city-integrated-desktop.png'), fullPage: true }); + await appointButton.click(); + await expect.poll(() => state.appointmentInputs).toEqual([{ destGeneralId: 21, destCityId: 1, officerLevel: 4 }]); + await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveText('장료'); + await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveClass(/effective-officer/u); + await expect(page.locator('.city[data-city-id="1"] tr[data-general-id="21"] .mode-4')).toBeDisabled(); + + await page.setViewportSize({ width: 500, height: 900 }); + expect(await page.locator('.nation-cities-page').evaluate((element) => element.getBoundingClientRect().width)).toBe( + 1000 + ); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); + await page.screenshot({ path: testInfo.outputPath('nation-city-integrated-mobile.png'), fullPage: true }); +}); + +test('수뇌 대상은 재확인하고 일반 장수에게는 임명 버튼을 열지 않는다', async ({ page }) => { + const headState: FixtureState = { role: 'head', appointed: false, appointmentInputs: [] }; + await install(page, headState); + await page.goto('nation/cities'); + await page.getByRole('button', { name: '암행부 연동' }).click(); + await page.getByRole('button', { name: '인사부 연동' }).click(); + + const chiefButton = page.getByRole('button', { name: '순욱을(를) 허창 태수로 임명' }); + expect(await chiefButton.evaluate((button) => getComputedStyle(button).color)).toBe('rgb(255, 0, 0)'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('수뇌입니다. 임명할까요?'); + await dialog.dismiss(); + }); + await chiefButton.click(); + await expect.poll(() => headState.appointmentInputs.length).toBe(0); + + await page.unroute(gameTrpcRoute); + const memberState: FixtureState = { role: 'member', appointed: false, appointmentInputs: [] }; + await install(page, memberState); + await page.reload(); + await page.getByRole('button', { name: '암행부 연동' }).click(); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('수뇌가 아닙니다!'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '인사부 연동' }).click(); + await expect(page.locator('.appointment-button')).toHaveCount(0); + expect(memberState.appointmentInputs).toEqual([]); +}); + +test('암행부 권한 거부는 도시 기밀 행과 인사부 연동을 열지 않는다', async ({ page }) => { + const state: FixtureState = { + role: 'member', + appointed: false, + secretForbidden: true, + appointmentInputs: [], + }; + await install(page, state); + await page.goto('nation/cities'); + await page.getByRole('button', { name: '암행부 연동' }).click(); + + await expect(page.locator('.integration-error')).toContainText('권한이 부족합니다.'); + await expect(page.locator('.city-user-table')).toHaveCount(0); + await expect(page.getByRole('button', { name: '인사부 연동' })).toHaveCount(0); + expect(state.appointmentInputs).toEqual([]); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 8fe08af6..734b6cee 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -21,6 +21,7 @@ export default defineConfig({ 'troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', + 'nationCityOfficeIntegration.spec.ts', 'inGameMenus.spec.ts', 'nationOffices.spec.ts', 'diplomacy.spec.ts', 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/src/views/NationCitiesView.vue b/app/game-frontend/src/views/NationCitiesView.vue index 10669e95..ae7a786b 100644 --- a/app/game-frontend/src/views/NationCitiesView.vue +++ b/app/game-frontend/src/views/NationCitiesView.vue @@ -1,16 +1,28 @@