diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 1f776107..45451a52 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -56,6 +56,7 @@ const zGeneralSettings = z.object({ use_treatment: z.number().int().optional(), use_auto_nation_turn: z.number().int().optional(), use_auto_nation_diplomacy: z.number().int().min(0).max(1).optional(), + use_auto_nation_war: z.number().int().min(0).max(1).optional(), use_auto_nation_promotion: z.number().int().min(0).max(1).optional(), use_auto_nation_finance: z.number().int().min(0).max(1).optional(), use_auto_nation_capital: z.number().int().min(0).max(1).optional(), @@ -218,6 +219,7 @@ const resolveUserSettings = (meta: Record) => { // Ref가 NPC 군주에게만 수행하던 국가 운영은 사용자 군주에게 opt-in이다. // 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다. use_auto_nation_diplomacy: readNumber(readSetting('use_auto_nation_diplomacy'), 0), + use_auto_nation_war: readNumber(readSetting('use_auto_nation_war'), 0), use_auto_nation_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0), use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0), use_auto_nation_capital: readNumber(readSetting('use_auto_nation_capital'), 0), diff --git a/app/game-api/test/createGeneral.integration.test.ts b/app/game-api/test/createGeneral.integration.test.ts index ad0c613f..52ff8068 100644 --- a/app/game-api/test/createGeneral.integration.test.ts +++ b/app/game-api/test/createGeneral.integration.test.ts @@ -287,12 +287,16 @@ integration('generic general creation through the durable turn daemon', () => { accountIconUpdatedAt: '2026-07-30T00:00:00.000Z', }); const createdAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: created.id } }); + const acceptedEvent = await db.inputEvent.findUniqueOrThrow({ + where: { requestId: `join-create:${userId}:${clientRequestId}` }, + }); if (!createdAccess.lastRefresh) { throw new Error('created general must have an initial access timestamp'); } + expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt); expect( new Date((created.meta as Record).prestart_delete_after as string).getTime() - - createdAccess.lastRefresh.getTime() + acceptedEvent.createdAt.getTime() ).toBe(2 * 5 * 60 * 1_000); expect(runtime!.world.getGeneralById(created.id)).toMatchObject({ id: created.id, @@ -354,7 +358,7 @@ integration('generic general creation through the durable turn daemon', () => { attempts: 1, actorUserId: userId, }); - expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime()); + expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime()); const turnGridOffsetSeconds = ((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300; expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35); diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index 17856e5f..1ae5ada0 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -585,6 +585,7 @@ describe('in-game my information ownership', () => { use_treatment: 21, use_auto_nation_turn: 1, use_auto_nation_diplomacy: 0, + use_auto_nation_war: 0, use_auto_nation_promotion: 0, use_auto_nation_finance: 0, use_auto_nation_capital: 0, @@ -600,6 +601,16 @@ describe('in-game my information ownership', () => { expect(fixture.db.general.update).not.toHaveBeenCalled(); }); + it('rejects an invalid automatic war setting before dispatching it to ENGINE', async () => { + const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 })); + const fixture = createContext({ requestCommand }); + + await expect( + appRouter.createCaller(fixture.context).general.setMySetting({ use_auto_nation_war: 2 }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(requestCommand).not.toHaveBeenCalled(); + }); + it('sends settings directly to ENGINE without creating an API input event', async () => { const transaction = vi.fn(async () => { throw new Error('API transaction must not run'); diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index d25a847e..26a3cfe1 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -258,12 +258,17 @@ integration('scenario 903 select pool through the durable turn daemon', () => { const initialRuntime = runtime!.world.getGeneralById(initial.id); const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } }); expect(initial).toMatchObject({ picture: 'default.jpg', imageServer: 0 }); + const acceptedEvent = await db.inputEvent.findFirstOrThrow({ + where: { actorUserId: userId, eventType: 'selectPoolCreate', status: 'SUCCEEDED' }, + orderBy: { sequence: 'desc' }, + }); if (!initialAccess.lastRefresh) { throw new Error('selected general must have an initial access timestamp'); } + expect(initialAccess.lastRefresh).toEqual(acceptedEvent.createdAt); expect( new Date((initial.meta as Record).prestart_delete_after as string).getTime() - - initialAccess.lastRefresh.getTime() + acceptedEvent.createdAt.getTime() ).toBe(2 * 5 * 60 * 1_000); expect(initialRuntime).toMatchObject({ id: initial.id, diff --git a/app/game-engine/src/turn/ai/policies.ts b/app/game-engine/src/turn/ai/policies.ts index e205afec..ea669aad 100644 --- a/app/game-engine/src/turn/ai/policies.ts +++ b/app/game-engine/src/turn/ai/policies.ts @@ -61,10 +61,11 @@ export const AVAILABLE_INSTANT_TURN: Record = { NPC전방발령: true, }; -export type UserRulerAutomationFeature = 'diplomacy' | 'promotion' | 'finance' | 'capital'; +export type UserRulerAutomationFeature = 'diplomacy' | 'war' | 'promotion' | 'finance' | 'capital'; const USER_RULER_AUTOMATION_META_KEY = { diplomacy: 'use_auto_nation_diplomacy', + war: 'use_auto_nation_war', promotion: 'use_auto_nation_promotion', finance: 'use_auto_nation_finance', capital: 'use_auto_nation_capital', @@ -72,7 +73,7 @@ const USER_RULER_AUTOMATION_META_KEY = { const USER_RULER_ACTION_FEATURE: Readonly> = { 불가침제의: 'diplomacy', - 선전포고: 'diplomacy', + 선전포고: 'war', 천도: 'capital', }; diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index adf16d54..4f15dad3 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -137,6 +137,7 @@ const zSetMySetting = z.object({ use_treatment: z.number().int().optional(), use_auto_nation_turn: z.number().int().optional(), use_auto_nation_diplomacy: z.number().int().optional(), + use_auto_nation_war: z.number().int().optional(), use_auto_nation_promotion: z.number().int().optional(), use_auto_nation_finance: z.number().int().optional(), use_auto_nation_capital: z.number().int().optional(), diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index 99c8697d..a10488fa 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -522,8 +522,9 @@ export const createGeneralFromJoin = async (options: { worldState: WorldStateRow; input: JoinCreateGeneralInput; acceptedAt: Date; + operationalAcceptedAt: Date; }): Promise<{ ok: true; generalId: number }> => { - const { db, world, worldState, input, acceptedAt } = options; + const { db, world, worldState, input, acceptedAt, operationalAcceptedAt } = options; await lockJoinMutation(db, input.userId); await assertGeneralIdSnapshotMatches(db, world); @@ -734,7 +735,10 @@ export const createGeneralFromJoin = async (options: { const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint; const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId); const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus; - const prestartDeleteAfter = buildPrestartDeleteAfter(acceptedAt, worldState.tickSeconds, config); + // Ref의 가오픈 삭제 대기는 정지된 게임 clock이 아니라 실제 요청 접수 시각부터 흐른다. + // 미래 정식 오픈에 clock을 고정한 PREOPEN에서도 사용자가 가오픈 중 두 턴을 기다리면 + // 삭제할 수 있어야 하므로 RNG/턴 배치용 acceptedAt과 이 벽시계 경계를 분리한다. + const prestartDeleteAfter = buildPrestartDeleteAfter(operationalAcceptedAt, worldState.tickSeconds, config); const general: TurnGeneral = { id: generalId, userId: input.userId, @@ -839,11 +843,11 @@ export const createGeneralFromJoin = async (options: { }); await db.generalAccessLog.upsert({ where: { generalId }, - update: { userId: input.userId, lastRefresh: acceptedAt }, + update: { userId: input.userId, lastRefresh: operationalAcceptedAt }, create: { generalId, userId: input.userId, - lastRefresh: acceptedAt, + lastRefresh: operationalAcceptedAt, }, }); if (inheritRequiredPoint > 0) { diff --git a/app/game-engine/src/turn/selectPoolService.ts b/app/game-engine/src/turn/selectPoolService.ts index 8079f245..10f832d1 100644 --- a/app/game-engine/src/turn/selectPoolService.ts +++ b/app/game-engine/src/turn/selectPoolService.ts @@ -702,6 +702,7 @@ export const createGeneralFromSelectionPool = async (options: { uniqueName: string; personality: string; now?: Date; + operationalAcceptedAt: Date; seedOwnerIdentity?: string | number; ownerPicture?: string; ownerImageServer?: number; @@ -766,7 +767,7 @@ export const createGeneralFromSelectionPool = async (options: { const nextChangeAt = new Date( now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000 ); - const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config); + const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config); // 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는 // 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다. const { picture, imageServer } = resolveSelectionPoolUserIcon({ @@ -908,8 +909,8 @@ export const createGeneralFromSelectionPool = async (options: { } await db.generalAccessLog.upsert({ where: { generalId }, - update: { userId, lastRefresh: now }, - create: { generalId, userId, lastRefresh: now }, + update: { userId, lastRefresh: options.operationalAcceptedAt }, + create: { generalId, userId, lastRefresh: options.operationalAcceptedAt }, }); await clearUnusedReservations(db, userId, now, nowTick); await synchronizeSelectionPoolWorld(db, world); diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index e1e69a84..45095881 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -296,6 +296,7 @@ async function handleJoinCreateGeneral( ...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}), }, acceptedAt, + operationalAcceptedAt, })), }; } catch (error) { @@ -366,7 +367,13 @@ async function handleSelectPoolCreate( if (!worldState) { throw new Error('Selection-pool world state is missing.'); } - const acceptedAt = await resolveSelectionCommandAcceptedAt(db, ctx.world, command); + const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command); + const acceptedAt = + command.acceptedGameTick !== undefined + ? ctx.world.gameTickToDate(command.acceptedGameTick) + : command.acceptedGameAt !== undefined + ? new Date(command.acceptedGameAt) + : ctx.world.getGameNow(operationalAcceptedAt); try { return { type: 'selectPoolCreate', @@ -383,6 +390,7 @@ async function handleSelectPoolCreate( ...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}), ...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}), now: acceptedAt, + operationalAcceptedAt, })), }; } catch (error) { @@ -1779,6 +1787,7 @@ async function handleSetMySetting( } for (const key of [ 'use_auto_nation_diplomacy', + 'use_auto_nation_war', 'use_auto_nation_promotion', 'use_auto_nation_finance', 'use_auto_nation_capital', @@ -1988,7 +1997,7 @@ async function handleKick( } const target = world.getGeneralById(command.destGeneralId); - if (!target || target.id === general.id || target.nationId !== general.nationId) { + if (!target || target.nationId !== general.nationId) { return { type: 'kick', ok: false, @@ -1996,7 +2005,18 @@ async function handleKick( reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.', }; } - if (resolveMaxSecretPermission(target) === 4 && resolvePermissionKind(target) === 'ambassador') { + if (target.id === general.id) { + return { type: 'kick', ok: false, generalId: command.generalId, reason: '본인은 추방할 수 없습니다.' }; + } + // Ref 화면은 군주와 본인을 후보에서 제외하지만 서버는 조작 요청을 막지 못했다. + // 국가 소유권을 깨뜨리는 대상은 UI와 무관하게 durable command 경계에서 거부한다. + if (target.id === nation.chiefGeneralId || target.officerLevel === 12) { + return { type: 'kick', ok: false, generalId: command.generalId, reason: '군주는 추방할 수 없습니다.' }; + } + if (target.officerLevel >= 5) { + return { type: 'kick', ok: false, generalId: command.generalId, reason: '수뇌는 추방할 수 없습니다.' }; + } + if (resolvePermissionKind(target) === 'ambassador') { return { type: 'kick', ok: false, diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index cab0554d..d1c76704 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -718,6 +718,7 @@ describe('legacy NPC user-chief promotion parity', () => { it('keeps user-ruler duties individually disabled until each setting is enabled', () => { const ruler = makePromotionGeneral({ id: 1, officerLevel: 12, npcState: 0, meta: { killturn: 0 } }); expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(false); + expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(false); expect(canUseAutomatedNationAction(ruler, '천도')).toBe(false); expect(canUseRulerAutomation(ruler, 'finance')).toBe(false); @@ -728,9 +729,17 @@ describe('legacy NPC user-chief promotion parity', () => { use_auto_nation_finance: 1, }; expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(true); - expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(true); + expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(false); expect(canUseAutomatedNationAction(ruler, '천도')).toBe(true); expect(canUseRulerAutomation(ruler, 'finance')).toBe(true); + + ruler.meta = { + ...ruler.meta, + use_auto_nation_diplomacy: 0, + use_auto_nation_war: 1, + }; + expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(false); + expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(true); }); it('honors the existing automatic nation-turn master switch for user chiefs only', () => { diff --git a/app/game-engine/test/myInformationCommands.test.ts b/app/game-engine/test/myInformationCommands.test.ts index 3b0d728c..fd5b7206 100644 --- a/app/game-engine/test/myInformationCommands.test.ts +++ b/app/game-engine/test/myInformationCommands.test.ts @@ -223,6 +223,7 @@ describe('my information world commands', () => { use_treatment: 200, use_auto_nation_turn: 0, use_auto_nation_diplomacy: 1, + use_auto_nation_war: 1, use_auto_nation_promotion: 1, use_auto_nation_finance: 1, use_auto_nation_capital: 1, @@ -239,6 +240,7 @@ describe('my information world commands', () => { use_treatment: 100, use_auto_nation_turn: 0, use_auto_nation_diplomacy: 1, + use_auto_nation_war: 1, use_auto_nation_promotion: 1, use_auto_nation_finance: 1, use_auto_nation_capital: 1, diff --git a/app/game-engine/test/nationPersonnelManagement.test.ts b/app/game-engine/test/nationPersonnelManagement.test.ts index aa42b49c..11391432 100644 --- a/app/game-engine/test/nationPersonnelManagement.test.ts +++ b/app/game-engine/test/nationPersonnelManagement.test.ts @@ -312,6 +312,39 @@ describe('nation personnel world commands', () => { expect(fixture.world.peekDirtyState().logs).toHaveLength(2); }); + it('rejects self, ruler, head officer, and ambassador targets without partial mutation', async () => { + const cases = [ + { label: 'self', targetId: 2, reason: '본인은 추방할 수 없습니다.' }, + { label: 'ruler', targetId: 1, reason: '군주는 추방할 수 없습니다.' }, + { label: 'head officer', targetId: 3, reason: '수뇌는 추방할 수 없습니다.' }, + { label: 'ambassador', targetId: 4, reason: '외교권자는 추방할 수 없습니다.' }, + ] as const; + + for (const testCase of cases) { + const fixture = buildWorld({ + generals: [ + buildGeneral(1, { officerLevel: 12 }), + buildGeneral(2, { officerLevel: 5 }), + buildGeneral(3, { officerLevel: 7 }), + buildGeneral(4, { + meta: { killturn: 12, belong: 5, permission: 'ambassador' }, + penalty: { noAmbassador: true }, + }), + buildGeneral(5), + ], + }); + const originalTarget = fixture.world.getGeneralById(testCase.targetId); + + await expect( + fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: testCase.targetId }) + ).resolves.toMatchObject({ ok: false, reason: testCase.reason }); + expect(fixture.world.getGeneralById(testCase.targetId), testCase.label).toEqual(originalTarget); + expect(fixture.world.getGeneralById(2)?.meta.killturn, testCase.label).toBe(12); + expect(fixture.world.peekDirtyState().logs, testCase.label).toEqual([]); + expect(fixture.world.peekDirtyState().nations, testCase.label).toEqual([]); + } + }); + it('preserves the legacy kick year boundaries and deterministic NPC public message', async () => { const early = buildWorld({ currentYear: 181, diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index e32182a9..c859f953 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -61,10 +61,10 @@ const castleFixtures = [ { id: 2, level: 1, layoutLevel: 8, x: 200, y: 100, width: 16, height: 15 }, { id: 3, level: 2, layoutLevel: 8, x: 300, y: 100, width: 20, height: 14 }, { id: 4, level: 3, layoutLevel: 8, x: 400, y: 100, width: 14, height: 14 }, - { id: 5, level: 4, layoutLevel: 8, x: 100, y: 220, width: 20, height: 15 }, - { id: 6, level: 5, layoutLevel: 8, x: 200, y: 220, width: 24, height: 16 }, - { id: 7, level: 6, layoutLevel: 8, x: 300, y: 220, width: 26, height: 18 }, - { id: 8, level: 7, layoutLevel: 8, x: 400, y: 220, width: 28, height: 20 }, + { id: 5, name: '남만', level: 4, layoutLevel: 8, x: 80, y: 455, width: 20, height: 15 }, + { id: 6, name: '교지', level: 5, layoutLevel: 8, x: 130, y: 480, width: 24, height: 16 }, + { id: 7, name: '남해', level: 6, layoutLevel: 8, x: 245, y: 480, width: 26, height: 18 }, + { id: 8, name: '대', level: 7, layoutLevel: 8, x: 450, y: 480, width: 28, height: 20 }, ] as const; const map = { result: true, @@ -82,9 +82,9 @@ const map = { }; const layout = { mapName: 'che', - cityList: castleFixtures.map(({ id, layoutLevel: level, x, y }) => ({ + cityList: castleFixtures.map(({ id, layoutLevel: level, x, y, ...fixture }) => ({ id, - name: id === 1 ? '업' : `성${id}`, + name: id === 1 ? '업' : 'name' in fixture ? fixture.name : `성${id}`, level, region: 1, x, @@ -550,6 +550,40 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0); await desktopCity.hover(); await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업'); + + for (const cityName of ['남만', '교지', '남해', '대']) { + await page.getByRole('link', { name: cityName, exact: true }).hover(); + await expect(page.locator('.map-tooltip')).toBeVisible(); + const geometry = await page.locator('.map-area').evaluate((mapArea, expectedCityName) => { + const cityElement = Array.from(mapArea.querySelectorAll('.city-base')).find( + (element) => element.getAttribute('aria-label') === expectedCityName + ); + const tooltip = mapArea.querySelector('.map-tooltip'); + if (!cityElement || !tooltip) throw new Error(`Missing bottom-city hover geometry for ${expectedCityName}`); + const mapRect = mapArea.getBoundingClientRect(); + const cityRect = cityElement.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const controls = mapArea.querySelector('.map-controls'); + const tooltipStyle = getComputedStyle(tooltip); + return { + map: { top: mapRect.top, bottom: mapRect.bottom }, + city: { top: cityRect.top, bottom: cityRect.bottom }, + tooltip: { top: tooltipRect.top, bottom: tooltipRect.bottom, height: tooltipRect.height }, + tooltipZIndex: Number(tooltipStyle.zIndex), + controlsZIndex: controls ? Number(getComputedStyle(controls).zIndex) : null, + pointerEvents: tooltipStyle.pointerEvents, + }; + }, cityName); + expect(geometry.tooltip.top).toBeGreaterThanOrEqual(geometry.map.top); + expect(geometry.tooltip.bottom).toBeLessThanOrEqual(geometry.map.bottom); + expect(geometry.tooltip.bottom).toBeLessThan(geometry.city.top); + expect(geometry.tooltip.height).toBeGreaterThanOrEqual(32); + expect(geometry.tooltipZIndex).toBeGreaterThan(geometry.controlsZIndex ?? 0); + expect(geometry.pointerEvents).toBe('none'); + } + await page.screenshot({ path: testInfo.outputPath('desktop-map-bottom-tooltip.png'), fullPage: true }); + + await desktopCity.hover(); await desktopCity.click(); await expect(page).toHaveURL(/\/current-city\?cityId=1$/u); await page.goBack(); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 32653a0c..817f931d 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -276,6 +276,7 @@ const myGeneral = (state: FixtureState) => ({ use_treatment: 21, use_auto_nation_turn: 1, use_auto_nation_diplomacy: 0, + use_auto_nation_war: 0, use_auto_nation_promotion: 0, use_auto_nation_finance: 0, use_auto_nation_capital: 0, @@ -1342,11 +1343,18 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide ]); const rulerAutomation = page.locator('.ruler-automation-settings'); await expect(rulerAutomation).toBeVisible(); - const diplomacyAutomation = page.getByRole('checkbox', { name: '자동 외교 (불가침 제의·선전포고)' }); + const diplomacyAutomation = page.getByRole('checkbox', { name: '자동 외교 (불가침 제의)' }); + const warAutomation = page.getByRole('checkbox', { name: '자동 선전포고' }); const promotionAutomation = page.getByRole('checkbox', { name: '자동 수뇌 임명' }); const financeAutomation = page.getByRole('checkbox', { name: '자동 세율·지급률 조정' }); const capitalAutomation = page.getByRole('checkbox', { name: '자동 천도' }); - for (const checkbox of [diplomacyAutomation, promotionAutomation, financeAutomation, capitalAutomation]) { + for (const checkbox of [ + diplomacyAutomation, + warAutomation, + promotionAutomation, + financeAutomation, + capitalAutomation, + ]) { await expect(checkbox).not.toBeChecked(); await checkbox.check(); } @@ -1410,6 +1418,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide expect(state.settingMutations[0]).not.toHaveProperty('generalId'); expect(state.settingMutations[0]).toMatchObject({ use_auto_nation_diplomacy: 1, + use_auto_nation_war: 1, use_auto_nation_promotion: 1, use_auto_nation_finance: 1, use_auto_nation_capital: 1, diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index 1c626da9..8d098d47 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -412,6 +412,13 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie expect(rowGeometry.gradientCount).toBe(0); await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0); await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible(); + await expect(page.getByRole('combobox', { name: '추방 대상 장수' }).locator('option')).toHaveText([ + '장수 선택', + '하후돈 (70/70/70)', + '곽가 (70/70/70)', + '정욱 (70/70/70)', + '장료 (70/70/70)', + ]); await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click(); const picker = page.getByTestId('personnel-selection-dialog'); diff --git a/app/game-frontend/src/components/main/MapViewer.vue b/app/game-frontend/src/components/main/MapViewer.vue index 85c7ff03..d757caee 100644 --- a/app/game-frontend/src/components/main/MapViewer.vue +++ b/app/game-frontend/src/components/main/MapViewer.vue @@ -92,6 +92,8 @@ const BASE_MAP_WIDTH = 700; const BASE_MAP_HEIGHT = 500; const SMALL_MAP_SCALE = 5 / 7; const MAP_BACKGROUND_TRANSITION_MS = 480; +const TOOLTIP_FALLBACK_HEIGHT = 32; +const TOOLTIP_VERTICAL_OFFSET = 30; const decodedImageCache = new Map>(); const decodedImageElements = new Map(); @@ -146,6 +148,7 @@ const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); const mapArea = ref(null); const mapBody = ref(null); const mapControls = ref(null); +const tooltipElement = ref(null); const mapOptionsOpen = ref(false); const mapOptionsMenuId = `map-options-${useId()}`; const { width: mapBodyWidth } = useElementSize(mapBody); @@ -568,10 +571,15 @@ const tooltipPosition = computed(() => { const width = 120; const offset = 10; const mapPixelWidth = BASE_MAP_WIDTH * mapScale.value; + const mapPixelHeight = BASE_MAP_HEIGHT * mapScale.value; + const tooltipHeight = tooltipElement.value?.offsetHeight ?? TOOLTIP_FALLBACK_HEIGHT; const left = elementX.value + width + offset > mapPixelWidth ? elementX.value - width - 5 : elementX.value + offset; + const belowTop = elementY.value + TOOLTIP_VERTICAL_OFFSET; + const top = + belowTop + tooltipHeight > mapPixelHeight ? elementY.value - tooltipHeight - TOOLTIP_VERTICAL_OFFSET : belowTop; return { left: `${Math.max(0, left)}px`, - top: `${elementY.value + 30}px`, + top: `${Math.max(0, top)}px`, }; }); @@ -698,7 +706,7 @@ const selectCity = (cityId: number) => { > 현재 -
+
{{ hoveredCityTitle }}
{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}
diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index ea9e1748..6f8d56d4 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -33,6 +33,7 @@ type SettingForm = { use_treatment: number; use_auto_nation_turn: number; use_auto_nation_diplomacy: number; + use_auto_nation_war: number; use_auto_nation_promotion: number; use_auto_nation_finance: number; use_auto_nation_capital: number; @@ -69,6 +70,7 @@ const form = reactive({ use_treatment: 10, use_auto_nation_turn: 1, use_auto_nation_diplomacy: 0, + use_auto_nation_war: 0, use_auto_nation_promotion: 0, use_auto_nation_finance: 0, use_auto_nation_capital: 0, @@ -432,7 +434,16 @@ onMounted(() => { :true-value="1" :false-value="0" /> - 자동 외교 (불가침 제의·선전포고) + 자동 외교 (불가침 제의) + +