diff --git a/app/game-api/src/messages/store.ts b/app/game-api/src/messages/store.ts index 2563cf06..cdf664c4 100644 --- a/app/game-api/src/messages/store.ts +++ b/app/game-api/src/messages/store.ts @@ -1,8 +1,8 @@ +import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra'; import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic'; import type { DatabaseClient } from '../context.js'; import { loadCurrentGameTime } from '../services/gameClock.js'; -import { enqueuePrivateMessageWebPush } from '@sammo-ts/infra'; export interface MessageView { id: number; @@ -203,3 +203,26 @@ export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Pro }, }); }; + +export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Promise => { + const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0))); + if (uniqueIds.length === 0) return; + + await db.$executeRaw( + GamePrisma.sql` + UPDATE message + SET message = jsonb_set( + jsonb_set(message, '{text}', to_jsonb(${'삭제된 메시지입니다.'}::text), true), + '{option}', + ( + CASE + WHEN jsonb_typeof(message->'option') = 'object' THEN message->'option' + ELSE '{}'::jsonb + END + ) || jsonb_build_object('invalid', true), + true + ) + WHERE id IN (${GamePrisma.join(uniqueIds)}) + ` + ); +}; diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 2a3fcca4..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), @@ -693,7 +695,13 @@ export const getGeneralContext = async (ctx: GameApiContext) => { export const generalRouter = router({ adjustIcon: engineAuthedProcedure .input( - z.object({ iconId: z.string().uuid().optional(), clientRequestId: z.string().uuid().optional() }).optional() + z + .object({ + iconId: z.string().uuid().optional(), + resetToDefault: z.literal(true).optional(), + clientRequestId: z.string().uuid().optional(), + }) + .optional() ) .mutation(({ ctx, input }) => { const userId = ctx.auth?.user.id; @@ -701,19 +709,41 @@ export const generalRouter = router({ throw new TRPCError({ code: 'UNAUTHORIZED' }); } const selected = input?.iconId ? ctx.auth?.user.icons?.find((icon) => icon.id === input.iconId) : undefined; - if (input?.iconId && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) { + const resetToDefault = input?.resetToDefault === true; + if (resetToDefault && input?.iconId) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘 선택과 기본 아이콘 초기화를 함께 요청할 수 없습니다.' }); + } + if (!resetToDefault && !input?.iconId) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '적용할 활성 전용 아이콘을 선택해 주세요.' }); + } + if ( + resetToDefault && + (ctx.auth?.user.picture !== 'default.jpg' || ctx.auth?.user.imageServer !== 0) + ) { + throw new TRPCError({ code: 'FORBIDDEN', message: '현재 계정 아이콘이 기본 아이콘이 아닙니다.' }); + } + if (!resetToDefault && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) { throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' }); } + const iconRevision = ctx.auth?.user.iconUpdatedAt ?? (resetToDefault ? undefined : selected!.createdAt); + if (!iconRevision) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '계정 아이콘 변경 시각을 확인할 수 없습니다.' }); + } + const projection = resetToDefault + ? { + picture: 'default.jpg', + imageServer: 0, + revision: iconRevision, + } + : { + picture: selected!.picture, + imageServer: selected!.imageServer, + revision: iconRevision, + }; return adjustAccountIconForUser( ctx, userId, - selected - ? { - picture: selected.picture, - imageServer: selected.imageServer, - revision: ctx.auth?.user.iconUpdatedAt ?? selected.createdAt, - } - : undefined, + projection, true, input?.clientRequestId ?? ctx.requestId ); diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index 37a503f2..2f218f48 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -14,7 +14,6 @@ import { WAR_TRAIT_KEYS, } from '@sammo-ts/logic'; import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js'; -import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js'; import { loadCurrentGameTime } from '../../services/gameClock.js'; import { getSelectionPoolStatus, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js'; import { @@ -515,15 +514,17 @@ export const joinRouter = router({ if (input.iconId && (!selectedIcon || auth.user.canUseGeneralPicture === false)) { throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' }); } - const accountIcon = input.pic - ? selectedIcon + // 유저 장수에는 인증 token의 활성 전용 아이콘을 명시적으로 고른 경우만 + // 그림을 적용한다. Gateway 대표 그림은 shared preset일 수 있으므로 + // iconId 없는 fallback으로 사용하지 않는다. + const accountIcon = + input.pic && selectedIcon ? { picture: selectedIcon.picture, imageServer: selectedIcon.imageServer, revision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt, } - : await loadAuthoritativeAccountIcon(ctx, userId) - : null; + : null; const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId); const result = await requestJoinCreateCommand(ctx, { type: 'joinCreateGeneral', @@ -535,7 +536,7 @@ export const joinRouter = router({ leadership: input.leadership, strength: input.strength, intel: input.intel, - pic: input.pic, + pic: accountIcon !== null, character: input.character, profileId: ctx.profile.id, ...(accountIcon diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index 8baaa5a7..c023ebc0 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -19,8 +19,8 @@ import { fetchMessagesFromMailbox, fetchOldMessagesFromMailbox, fetchMessageById, - invalidateMessages, insertMessage, + tombstoneMessages, type MessageView, } from '../../messages/store.js'; import { getOwnedGeneral } from '../shared/general.js'; @@ -40,11 +40,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M } return { ...message, - text: '(외교 메시지입니다)', - option: { - ...(message.option ?? {}), - invalid: true, - }, + text: '조회 권한이 없는 외교 메시지입니다.', }; }); }; @@ -303,7 +299,7 @@ export const messagesRouter = router({ message.id, ...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []), ]; - await invalidateMessages(ctx.db, ids); + await tombstoneMessages(ctx.db, ids); const receiverMailbox = shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private' ? message.payload.dest.generalId diff --git a/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts b/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts index 7d4f46b6..219a8f6e 100644 --- a/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts +++ b/app/game-api/src/router/nation/endpoints/getPersonnelInfo.ts @@ -153,11 +153,17 @@ export const getPersonnelInfo = accessAuthedProcedure.query(async ({ ctx }) => { const canChangePermissions = me.officerLevel === 12; const ambassadors = canChangePermissions ? permissionCandidates.filter( - (candidate) => candidate.permission === 'ambassador' || candidate.maxPermission === 4 + (candidate) => + candidate.permission === 'ambassador' || + (candidate.permission === 'normal' && candidate.maxPermission === 4) ) : []; const auditors = canChangePermissions - ? permissionCandidates.filter((candidate) => candidate.permission === 'auditor' || candidate.maxPermission >= 3) + ? permissionCandidates.filter( + (candidate) => + candidate.permission === 'auditor' || + (candidate.permission === 'normal' && candidate.maxPermission >= 3) + ) : []; const generalNameMap = new Map(mappedGenerals.map((general) => [general.id, general.name])); const awards = { diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index ca92a4cf..2f5213d8 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -433,7 +433,6 @@ export const voteRouter = router({ ) .mutation(async ({ ctx, input }) => { const general = await getMyGeneral(ctx); - const openerName = ctx.auth?.user.username ?? general.name; const options = normalizeOptions(input.options); if (options.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: '항목이 없습니다.' }); @@ -488,7 +487,7 @@ export const voteRouter = router({ ${multipleOptions}, ${input.revealMode}, ${general.id}, - ${openerName}, + ${general.name}, ${gameTime.now}, ${gameTime.tick === null ? null : BigInt(gameTime.tick)}, ${endAt}, diff --git a/app/game-api/src/services/accountIconSync.ts b/app/game-api/src/services/accountIconSync.ts index ed6b724b..c8c99f73 100644 --- a/app/game-api/src/services/accountIconSync.ts +++ b/app/game-api/src/services/accountIconSync.ts @@ -40,7 +40,7 @@ export const loadAuthoritativeAccountIcon = async ( export const adjustAccountIconForUser = async ( ctx: GameApiContext, userId: string, - selected?: AccountIconProjection, + selected: AccountIconProjection, enforceCooldown = true, requestKey?: string ): Promise<{ @@ -48,10 +48,8 @@ export const adjustAccountIconForUser = async ( generalId: number | null; updated: boolean; }> => { - const projection = selected ?? (await loadAuthoritativeAccountIcon(ctx, userId)); - const requestId = selected - ? `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}` - : `general:adjustIcon:${userId}:${projection.revision}`; + const projection = selected; + const requestId = `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`; try { const result = await ctx.turnDaemon.requestCommand({ type: 'adjustGeneralIcon', 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/messageTombstone.integration.test.ts b/app/game-api/test/messageTombstone.integration.test.ts new file mode 100644 index 00000000..c3bf6e02 --- /dev/null +++ b/app/game-api/test/messageTombstone.integration.test.ts @@ -0,0 +1,84 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { tombstoneMessages } from '../src/messages/store.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); + +integration('message deletion tombstone persistence', () => { + let db: GamePrismaClient; + let close: (() => Promise) | undefined; + + beforeAll(async () => { + const schema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null; + if (!schema?.endsWith('conditional_integration')) { + throw new Error(`Unsafe schema: ${schema ?? '(missing)'}`); + } + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + close = () => connector.disconnect(); + }); + + afterAll(async () => close?.()); + + it('keeps sender and receiver rows readable while replacing their bodies', async () => { + const rollback = new Error('rollback message tombstone fixture'); + await expect( + db.$transaction(async (transaction) => { + const validUntil = new Date('9999-12-31T00:00:00.000Z'); + const receiver = await transaction.message.create({ + data: { + mailbox: 8, + type: 'private', + src: 7, + dest: 8, + time: new Date('2026-08-24T00:00:00.000Z'), + validUntil, + message: { + src: { generalId: 7 }, + dest: { generalId: 8 }, + text: '수신 사본 원문', + option: { senderMessageID: 0 }, + }, + }, + }); + const sender = await transaction.message.create({ + data: { + mailbox: 7, + type: 'private', + src: 7, + dest: 8, + time: new Date('2026-08-24T00:00:00.000Z'), + validUntil, + message: { + src: { generalId: 7 }, + dest: { generalId: 8 }, + text: '송신 사본 원문', + option: { receiverMessageID: receiver.id }, + }, + }, + }); + + await tombstoneMessages(transaction, [sender.id, receiver.id]); + + const rows = await transaction.message.findMany({ + where: { id: { in: [sender.id, receiver.id] } }, + orderBy: { id: 'asc' }, + }); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.validUntil).toEqual(validUntil); + expect(row.message).toMatchObject({ + text: '삭제된 메시지입니다.', + option: { invalid: true }, + }); + expect(JSON.stringify(row.message)).not.toContain('사본 원문'); + } + + throw rollback; + }) + ).rejects.toBe(rollback); + }); +}); diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index bb6f4562..5eebe448 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -176,13 +176,15 @@ describe('messages router missing-flow compatibility', () => { expect(recent.permission).toBe(2); expect(recent.diplomacy[0]).toMatchObject({ - text: '(외교 메시지입니다)', - option: { action: 'noAggression', invalid: true }, + text: '조회 권한이 없는 외교 메시지입니다.', + option: { action: 'noAggression' }, }); + expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid'); expect(old.diplomacy[0]).toMatchObject({ - text: '(외교 메시지입니다)', - option: { action: 'noAggression', invalid: true }, + text: '조회 권한이 없는 외교 메시지입니다.', + option: { action: 'noAggression' }, }); + expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid'); }); it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => { @@ -585,15 +587,13 @@ describe('messages router missing-flow compatibility', () => { }, ]); const changeJournal = new ChangeJournal(); - const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal }); + const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal }); const result = await caller.messages.delete({ generalId: general.id, messageId: 21 }); expect(result.deletedIds).toEqual([21, 22]); - expect(updateMany).toHaveBeenCalledWith({ - where: { id: { in: [21, 22] } }, - data: { validUntil: expect.any(Date) }, - }); + expect(executeRaw).toHaveBeenCalledOnce(); + expect(updateMany).not.toHaveBeenCalled(); expect(changeJournal.snapshot()).toEqual([ { domain: 'messages.mailbox', entityId: 7 }, { domain: 'messages.mailbox', entityId: 8 }, @@ -632,15 +632,13 @@ describe('messages router missing-flow compatibility', () => { }, }, ]); - const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }); + const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }); const result = await caller.messages.delete({ generalId: general.id, messageId: 25 }); expect(result.deletedIds).toEqual([25]); - expect(updateMany).toHaveBeenCalledWith({ - where: { id: { in: [25] } }, - data: { validUntil: expect.any(Date) }, - }); + expect(executeRaw).toHaveBeenCalledOnce(); + expect(updateMany).not.toHaveBeenCalled(); }); it('rejects deleting another general message', async () => { diff --git a/app/game-api/test/nationPersonnelRouter.test.ts b/app/game-api/test/nationPersonnelRouter.test.ts index b9319798..59424490 100644 --- a/app/game-api/test/nationPersonnelRouter.test.ts +++ b/app/game-api/test/nationPersonnelRouter.test.ts @@ -230,6 +230,45 @@ describe('nation personnel router', () => { expect(result.awards.eagles).toEqual([{ id: 30, name: '군사', value: 7 }]); }); + it('keeps ambassador and auditor candidate pools mutually exclusive like Ref', async () => { + const me = { ...baseGeneral, officerLevel: 12 }; + const rows = [ + listRow({ id: 22, name: '군주', officerLevel: 12 }), + listRow({ id: 30, name: '현 외교권자', meta: { belong: 5, permission: 'ambassador' } }), + listRow({ id: 31, name: '현 조언자', meta: { belong: 5, permission: 'auditor' } }), + listRow({ id: 32, name: '일반 후보' }), + listRow({ id: 33, name: '외교 금지', penalty: { noAmbassador: true } }), + ]; + const context = createContext({ + me, + db: { + nation: { + findUnique: vi.fn(async () => ({ + id: 1, + name: '위', + color: '#777777', + level: 3, + typeCode: 'che_법가', + capitalCityId: 1, + meta: { chief_set: 0 }, + })), + }, + city: { findMany: vi.fn(async () => []) }, + troop: { findMany: vi.fn(async () => []) }, + general: { + findFirst: vi.fn(async () => me), + findMany: vi.fn(async () => rows), + }, + worldState: { findFirst: vi.fn(async () => ({ config: { stat: { chiefMin: 65 } } })) }, + rankData: { findMany: vi.fn(async () => []) }, + }, + }); + + const result = await appRouter.createCaller(context).nation.getPersonnelInfo(); + expect(result.permissionCandidates.ambassadors.map((candidate) => candidate.id)).toEqual([30, 32]); + expect(result.permissionCandidates.auditors.map((candidate) => candidate.id)).toEqual([31, 32]); + }); + it('allows finance mutations only for a head officer or an eligible ambassador', async () => { const nationDb = { nation: { diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index cb96afc2..687c02dc 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -388,45 +388,56 @@ describe('appRouter', () => { }); }); - it('applies the current Gateway database icon instead of stale token claims', async () => { + it('rejects icon adjustment without an explicitly selected active icon', async () => { const transport = new InMemoryTurnDaemonTransport(); - const currentAccountIcon = { - revision: '2026-07-31T09:00:00.000Z', - picture: 'latest.png', - imageServer: 1, - }; const auth = buildAuth(); - auth.user.picture = 'stale.png'; + auth.user.picture = '장수/유비.jpg'; auth.user.imageServer = 0; auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z'; - const requestId = `general:adjustIcon:${auth.user.id}:${currentAccountIcon.revision}`; + const accountIconGet = vi.fn(async () => ({ + revision: '2026-07-31T09:00:00.000Z', + picture: '장수/유비.jpg', + imageServer: 0, + })); + const caller = appRouter.createCaller( + buildContext({ + auth, + transport, + accountIconGet, + }) + ); + + await expect(caller.general.adjustIcon()).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect(caller.general.adjustIcon({ resetToDefault: true })).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(accountIconGet).not.toHaveBeenCalled(); + expect(transport.commands).toHaveLength(0); + }); + + it('allows an explicit default reset only when the signed account projection is default', async () => { + const transport = new InMemoryTurnDaemonTransport(); + const auth = buildAuth(); + const revision = '2026-07-31T09:00:00.000Z'; + auth.user.picture = 'default.jpg'; + auth.user.imageServer = 0; + auth.user.iconUpdatedAt = revision; + const requestId = `general:adjustIcon:${auth.user.id}:manual:${revision}:default.jpg`; transport.setCommandResult(requestId, { type: 'adjustGeneralIcon', ok: true, generalId: 1, updated: true, }); - const caller = appRouter.createCaller( - buildContext({ - auth, - transport, - currentAccountIcon, - }) - ); + const caller = appRouter.createCaller(buildContext({ auth, transport })); - await expect(caller.general.adjustIcon()).resolves.toEqual({ + await expect(caller.general.adjustIcon({ resetToDefault: true })).resolves.toMatchObject({ ok: true, - generalId: 1, updated: true, }); - expect(transport.commands.at(-1)?.command).toEqual({ - type: 'adjustGeneralIcon', + expect(transport.commands.at(-1)?.command).toMatchObject({ requestId, - userId: auth.user.id, - picture: 'latest.png', - imageServer: 1, - iconRevision: currentAccountIcon.revision, - enforceCooldown: true, + picture: 'default.jpg', + imageServer: 0, + iconRevision: revision, }); }); @@ -461,13 +472,13 @@ describe('appRouter', () => { }); }); - it('rejects icon adjustment without auth or a current Gateway account', async () => { + it('rejects icon adjustment without auth or a selected icon', async () => { await expect(appRouter.createCaller(buildContext({ auth: null })).general.adjustIcon()).rejects.toMatchObject({ code: 'UNAUTHORIZED', }); await expect( appRouter.createCaller(buildContext({ auth: buildAuth() })).general.adjustIcon() - ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); }); it('rejects unauthenticated or game-blocked auth status checks', async () => { @@ -581,30 +592,30 @@ describe('appRouter', () => { expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision'); }); - it('uses the authoritative projection instead of stale token claims for picture creation', async () => { + it('does not apply a shared Gateway representative when no active icon id was selected', async () => { const transport = new InMemoryTurnDaemonTransport(); const clientRequestId = '824454da-d0ab-48d2-a7d5-e2e5aaf83ba4'; const requestId = `join-create:user-1:${clientRequestId}`; - const revision = '2026-07-31T09:00:00.001Z'; transport.setCommandResult(requestId, { type: 'joinCreateGeneral', ok: true, generalId: 42, }); const auth = buildAuth(); - auth.user.picture = 'stale.png'; + auth.user.picture = '장수/유비.jpg'; auth.user.imageServer = 0; auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z'; + const accountIconGet = vi.fn(async () => ({ + revision: '2026-07-31T09:00:00.001Z', + picture: '장수/유비.jpg', + imageServer: 0, + })); const caller = appRouter.createCaller( buildContext({ state: buildWorldState(), auth, transport, - currentAccountIcon: { - revision, - picture: 'latest.png', - imageServer: 1, - }, + accountIconGet, }) ); @@ -618,11 +629,11 @@ describe('appRouter', () => { clientRequestId, }); - expect(transport.commands.at(-1)?.command).toMatchObject({ - ownerPicture: 'latest.png', - ownerImageServer: 1, - ownerIconRevision: revision, - }); + expect(accountIconGet).not.toHaveBeenCalled(); + expect(transport.commands.at(-1)?.command).toMatchObject({ pic: false }); + expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerPicture'); + expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerImageServer'); + expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision'); }); it('creates a general with the selected authenticated icon and rejects another icon id', async () => { diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index 3ae96910..12f6da45 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -257,18 +257,25 @@ integration('scenario 903 select pool through the durable turn daemon', () => { const initial = await db.general.findFirstOrThrow({ where: { userId } }); 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, userId, name: initial.name, - imageServer: initial.imageServer, + imageServer: 0, + picture: 'default.jpg', stats: { leadership: initial.leadership, strength: initial.strength, @@ -380,15 +387,15 @@ integration('scenario 903 select pool through the durable turn daemon', () => { intel: target.intel, personalCode: initial.personalCode, specialCode: target.specialDomestic, - imageServer: target.imageServer, - picture: target.picture, + imageServer: 0, + picture: 'default.jpg', }); expect(runtime!.world.getGeneralById(initial.id)).toMatchObject({ id: initial.id, userId, name: target.generalName, - imageServer: target.imageServer, - picture: target.picture, + imageServer: 0, + picture: 'default.jpg', stats: { leadership: target.leadership, strength: target.strength, diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts index dbb75419..5305d450 100644 --- a/app/game-api/test/voteRouter.test.ts +++ b/app/game-api/test/voteRouter.test.ts @@ -267,7 +267,7 @@ describe('vote router actor and permission boundaries', () => { expect(fixture.redisPublish).not.toHaveBeenCalled(); }); - it('publishes a global front-status projection after creating a survey', async () => { + it('stores the authenticated general name and publishes a global projection after creating a survey', async () => { const auth = buildAuth(['admin.survey.open']); auth.user.username = 'admin-account'; auth.user.displayName = '관리자 표시명'; @@ -288,8 +288,9 @@ describe('vote router actor and permission boundaries', () => { const insert = fixture.queryRaw.mock.calls .map(([query]) => query) .find((query) => sqlText(query).includes('INSERT INTO vote_poll')); - expect(insert?.values).toContain('admin-account'); - expect(insert?.values).not.toContain('관리자 장수'); + expect(insert?.values).toContain('관리자 장수'); + expect(insert?.values).not.toContain('admin-account'); + expect(insert?.values).not.toContain('관리자 표시명'); }); it('binds current operational timestamps in every raw SQL vote writer', async () => { 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/incomeHandler.ts b/app/game-engine/src/turn/incomeHandler.ts index e014884e..fe3252fd 100644 --- a/app/game-engine/src/turn/incomeHandler.ts +++ b/app/game-engine/src/turn/incomeHandler.ts @@ -174,7 +174,10 @@ const processIncomeForNation = ( world.updateNation(nation.id, { rice: next, meta: nextMeta }); } - const incomeText = incomeValue.toLocaleString(); + // Ref keeps the fractional pre-flush value for the payout ratio and + // prev_income_* metadata, but number_format() rounds the user-facing log + // to the same integer precision as the persisted resource column. + const incomeText = Math.round(incomeValue).toLocaleString('en-US'); const incomeLog = type === 'gold' ? `이번 수입은 금 ${incomeText}입니다.` : `이번 수입은 쌀 ${incomeText}입니다.`; for (const general of nationGenerals) { 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 ceb255ba..10f832d1 100644 --- a/app/game-engine/src/turn/selectPoolService.ts +++ b/app/game-engine/src/turn/selectPoolService.ts @@ -54,6 +54,18 @@ const DEFAULT_CREW_TYPE_ID = 1100; const MAX_GENERAL_TURNS = 30; const DEFAULT_TURN_ACTION = '휴식'; +export const resolveSelectionPoolUserIcon = (options: { + showImgLevel: number; + ownerPicture?: string; + ownerImageServer?: number; +}): { picture: string; imageServer: number } => { + const useOwnerPicture = + options.showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg'; + return useOwnerPicture + ? { picture: options.ownerPicture!, imageServer: options.ownerImageServer ?? 1 } + : { picture: 'default.jpg', imageServer: 0 }; +}; + const zCandidateInfo = z.object({ uniqueName: z.string().min(1), generalName: z.string().min(1), @@ -690,6 +702,7 @@ export const createGeneralFromSelectionPool = async (options: { uniqueName: string; personality: string; now?: Date; + operationalAcceptedAt: Date; seedOwnerIdentity?: string | number; ownerPicture?: string; ownerImageServer?: number; @@ -754,12 +767,15 @@ 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 showImgLevel = asNumber(config.showImgLevel, 0); - const useOwnerPicture = - showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg'; - const picture = useOwnerPicture ? options.ownerPicture! : showImgLevel >= 3 ? info.picture : 'default.jpg'; - const imageServer = useOwnerPicture ? (options.ownerImageServer ?? 1) : info.imgsvr; + const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config); + // 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는 + // 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다. + const { picture, imageServer } = resolveSelectionPoolUserIcon({ + showImgLevel: asNumber(config.showImgLevel, 0), + ownerPicture: options.ownerPicture, + ownerImageServer: options.ownerImageServer, + }); + const useOwnerPicture = picture !== 'default.jpg'; const defaultSpecialWar = typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None'; const defaultSpecialDomestic = @@ -893,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); @@ -1022,6 +1038,7 @@ export const reselectGeneralFromSelectionPool = async (options: { now ), }; + const reselectionIcon = resolveSelectionPoolUserIcon({ showImgLevel: 0 }); const updated = world.updateGeneral(general.id, { name: info.generalName, stats: centennialGrowth?.stats ?? { @@ -1035,8 +1052,10 @@ export const reselectGeneralFromSelectionPool = async (options: { specialDomestic: info.specialDomestic, specialWar: info.specialWar ?? general.role.specialWar, }, - picture: info.picture, - imageServer: info.imgsvr, + // 재선택 후보의 preset은 유저 장수에 이어 붙이지 않는다. 전용 아이콘을 + // 다시 고르는 UI가 없는 현재 경로는 안전한 기본 아이콘으로 되돌린다. + picture: reselectionIcon.picture, + imageServer: reselectionIcon.imageServer, meta: updatedMeta, }); if (!updated) { diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index e015373c..1aeb2647 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/monthlyCoreEventAction.test.ts b/app/game-engine/test/monthlyCoreEventAction.test.ts index 2e65f899..0b1532db 100644 --- a/app/game-engine/test/monthlyCoreEventAction.test.ts +++ b/app/game-engine/test/monthlyCoreEventAction.test.ts @@ -373,6 +373,13 @@ describe('core monthly event actions at the real month boundary', () => { await world.advanceMonth(new Date('0191-01-01T00:00:00.000Z')); expect(world.getNationById(1)?.meta.prev_income_gold).toBe(157.5); + expect(world.peekDirtyState().logs).toContainEqual( + expect.objectContaining({ + generalId: 1, + text: '이번 수입은 금 158입니다.', + }) + ); + expect(world.peekDirtyState().logs.map((entry) => entry.text).join('\n')).not.toContain('157.5'); }); it('uses the Ref default nation resource floors when scenario const omits them', async () => { 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-engine/test/selectPoolReservation.test.ts b/app/game-engine/test/selectPoolReservation.test.ts index 2fda41c0..75cb562d 100644 --- a/app/game-engine/test/selectPoolReservation.test.ts +++ b/app/game-engine/test/selectPoolReservation.test.ts @@ -4,7 +4,7 @@ import { GAME_TICKS_PER_TURN } from '@sammo-ts/common'; import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; -import { reserveSelectionPool } from '../src/turn/selectPoolService.js'; +import { reserveSelectionPool, resolveSelectionPoolUserIcon } from '../src/turn/selectPoolService.js'; import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; interface TestPoolRow { @@ -171,6 +171,27 @@ const worldState = { }; describe('selection-pool reservation command state', () => { + it('uses only an explicitly selected owner icon for a human general', () => { + expect(resolveSelectionPoolUserIcon({ showImgLevel: 3 })).toEqual({ + picture: 'default.jpg', + imageServer: 0, + }); + expect( + resolveSelectionPoolUserIcon({ + showImgLevel: 3, + ownerPicture: 'uploaded/user.png', + ownerImageServer: 1, + }) + ).toEqual({ picture: 'uploaded/user.png', imageServer: 1 }); + expect( + resolveSelectionPoolUserIcon({ + showImgLevel: 0, + ownerPicture: 'uploaded/user.png', + ownerImageServer: 1, + }) + ).toEqual({ picture: 'default.jpg', imageServer: 0 }); + }); + it('excludes current reservations and keeps serialized users disjoint in DB and memory', async () => { const rows = buildRows(); const world = buildWorld(rows); 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 ea13a384..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, @@ -1981,6 +1990,40 @@ test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다' expect(state.createGeneralInputs?.[0]).toMatchObject({ pic: true, iconId: secondIconId }); }); +test('활성 전용 아이콘이 없으면 대표 preset을 장수 생성 요청에 전달하지 않는다', async ({ page }) => { + const state: FixtureState = { + permission: 'member', + myset: 1, + settingMutations: [], + accessPages: [], + createGeneralInputs: [], + joinConfig: { + rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true }, + user: { + id: 'user-1', + displayName: '생성장수', + canCreateGeneral: true, + preferredPicture: '장수/유비.jpg', + icons: [], + }, + personalities: [{ key: 'Random', name: '???', info: '무작위 성격' }], + nations: [], + selectionPool: { enabled: false }, + npcPossession: { enabled: false }, + inherit: null, + }, + }; + await install(page, state); + await page.goto('join'); + + await expect(page.getByRole('radiogroup', { name: '전용 아이콘 선택' })).toHaveCount(0); + await page.getByRole('button', { name: '장수 생성', exact: true }).last().click(); + + await expect.poll(() => state.createGeneralInputs?.length ?? 0).toBe(1); + expect(state.createGeneralInputs?.[0]).toMatchObject({ pic: false }); + expect(state.createGeneralInputs?.[0]).not.toHaveProperty('iconId'); +}); + test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 새 ID를 만든다', async ({ page }) => { const state: FixtureState = { permission: 'head', diff --git a/app/game-frontend/e2e/legacyLogHtml.spec.ts b/app/game-frontend/e2e/legacyLogHtml.spec.ts index 66f73ea5..e1e1efee 100644 --- a/app/game-frontend/e2e/legacyLogHtml.spec.ts +++ b/app/game-frontend/e2e/legacyLogHtml.spec.ts @@ -23,6 +23,10 @@ const history = [ '강조' + '오염 이름', }, + { + id: 3, + text: '이번 수입은 금 158입니다.', + }, ]; const publicResponse = (operation: string): unknown => { @@ -59,10 +63,12 @@ for (const viewport of [ await page.goto('public'); const lines = page.locator('.recent-log-line'); - await expect(lines).toHaveCount(2); + await expect(lines).toHaveCount(3); await expect(lines.nth(0).locator('b')).toHaveText('안전 강조'); await expect(lines.nth(1).locator('.small_war_log .war_type_attack')).toHaveText('→'); await expect(lines.nth(1).locator('.ev_highlight')).toHaveText('강조'); + await expect(lines.nth(2)).toHaveText('이번 수입은 금 158입니다.'); + await expect(lines.nth(2)).not.toContainText('157.5'); await expect(lines.locator('script, img, svg, a, [onerror], [onclick], [style*="url"]')).toHaveCount(0); await expect(lines.nth(0)).toContainText(' entry.officerLevel >= 2) : fullGenerals; @@ -145,8 +148,13 @@ const personnelFixture = (state: FixtureState) => { ambassadors: [ { id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 }, { id: 7, name: '허저', npcState: 0, permission: 'ambassador', maxPermission: 4 }, + { id: 9, name: '전위', npcState: 0, permission: 'normal', maxPermission: 4 }, + ], + auditors: [ + { id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 }, + { id: 8, name: '가후', npcState: 0, permission: 'auditor', maxPermission: 4 }, + { id: 9, name: '전위', npcState: 0, permission: 'normal', maxPermission: 4 }, ], - auditors: [{ id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 }], } : { ambassadors: [], auditors: [] }, }; @@ -238,7 +246,16 @@ const installFixture = async (page: Page, state: FixtureState) => { state.appointedOfficerLevel = Number(jsonInput.officerLevel ?? 0); return response({ ok: true }); } - if (operation === 'nation.kick' || operation === 'nation.changePermission') return response({ ok: true }); + if (operation === 'nation.changePermission') { + state.permissionMutationInput = { + isAmbassador: jsonInput.isAmbassador === true, + targetGeneralIds: Array.isArray(jsonInput.targetGeneralIds) + ? jsonInput.targetGeneralIds.map((id) => Number(id)) + : [], + }; + return response({ ok: true }); + } + if (operation === 'nation.kick') return response({ ok: true }); if (operation === 'nation.setRate') { if (state.failNextRate) { state.failNextRate = false; @@ -347,6 +364,62 @@ test('personnel keeps the desktop frame while exposing row-level appointment con await screenshot(page, 'core-personnel-desktop-leader.png'); }); +test('leader can grant two ambassador and auditor permissions by click or touch without modifier keys', async ({ + page, +}) => { + const state: FixtureState = { role: 'leader', rate: 20 }; + await installFixture(page, state); + await page.setViewportSize({ width: 390, height: 844 }); + await gotoOffice(page, 'nation/personnel'); + + const ambassadorTrigger = page.locator('.permission-multiselect-trigger').first(); + await expect(ambassadorTrigger).toHaveAccessibleName('외교권자 선택, 현재 1명'); + await ambassadorTrigger.click(); + const ambassadorOptions = page.getByRole('listbox', { name: '외교권자 후보' }); + await expect(ambassadorOptions).toBeVisible(); + await expect(ambassadorOptions.getByRole('option', { name: '허저' })).toHaveAttribute('aria-selected', 'true'); + await ambassadorOptions.getByRole('option', { name: '장료' }).click(); + await expect(ambassadorOptions.getByRole('option', { name: '장료' })).toHaveAttribute('aria-selected', 'true'); + await expect(ambassadorTrigger).toHaveAccessibleName('외교권자 선택, 현재 2명'); + + await ambassadorOptions.getByRole('option', { name: '전위' }).click(); + await expect(page.getByTestId('game-toast')).toContainText('최대 2명까지 설정 가능합니다.'); + await expect(ambassadorOptions.getByRole('option', { name: '전위' })).toHaveAttribute('aria-selected', 'false'); + const ambassadorGeometry = await ambassadorOptions.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { left: rect.left, right: rect.right, width: rect.width }; + }); + expect(ambassadorGeometry.left).toBeGreaterThanOrEqual(0); + expect(ambassadorGeometry.right).toBeLessThanOrEqual(390); + expect(ambassadorGeometry.width).toBeGreaterThanOrEqual(100); + await screenshot(page, 'core-personnel-mobile-permission-picker-open.png'); + + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('외교권자를 변경할까요?'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '외교권자 임명 반영' }).click(); + await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible(); + await expect.poll(() => state.permissionMutationInput).toEqual({ isAmbassador: true, targetGeneralIds: [7, 6] }); + + const auditorTrigger = page.locator('.permission-multiselect-trigger').nth(1); + await expect(auditorTrigger).toHaveAccessibleName('조언자 선택, 현재 1명'); + await auditorTrigger.click(); + const auditorOptions = page.getByRole('listbox', { name: '조언자 후보' }); + await expect(auditorOptions.getByRole('option', { name: '허저' })).toHaveCount(0); + await auditorOptions.getByRole('option', { name: '장료' }).click(); + await expect(auditorTrigger).toHaveAccessibleName('조언자 선택, 현재 2명'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('조언자를 변경할까요?'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '조언자 임명 반영' }).click(); + await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible(); + await expect.poll(() => state.permissionMutationInput).toEqual({ isAmbassador: false, targetGeneralIds: [8, 6] }); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390); + await screenshot(page, 'core-personnel-mobile-permission-picker.png'); +}); + test('personnel selects an informed general and reports the JosaUtil-composed result in a toast', async ({ page }) => { const state: FixtureState = { role: 'head', rate: 20 }; await installFixture(page, state); @@ -412,6 +485,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/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts index 2def11c0..60bbe314 100644 --- a/app/game-frontend/e2e/npcPolicy.spec.ts +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -375,19 +375,52 @@ test('physical mobile touch reorders NPC priority across active and inactive lis } }); -test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => { - const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] }; +test('a read-level user may edit drafts but cannot reset, revert, or submit them', async ({ page }) => { + const state: FixtureState = { permissionLevel: 1, mutations: [] }; await installFixture(page, state); await gotoPolicy(page); const input = page.getByLabel('국가 권장 금'); await expect(input).toBeEnabled(); await input.fill('23456'); - page.once('dialog', (dialog) => dialog.accept()); - await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); - await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); + + const nationPanel = page.locator('.priority-panel').first(); + const activePriority = nationPanel.locator('.priority-column').nth(1).getByText('불가침제의'); + const inactivePriorityList = nationPanel.locator('.priority-column').first().locator('.priority-list'); + await activePriority.dragTo(inactivePriorityList); + await expect(inactivePriorityList.getByText('불가침제의')).toBeVisible(); + + const actionButtons = page.locator('.control_bar button'); + await expect(actionButtons).toHaveCount(9); + for (const button of await actionButtons.all()) { + await expect(button).toBeDisabled(); + } + + const disabledStyle = await actionButtons.first().evaluate((element) => { + const style = getComputedStyle(element); + return { cursor: style.cursor, opacity: style.opacity, filter: style.filter }; + }); + expect(disabledStyle).toEqual({ cursor: 'not-allowed', opacity: '0.55', filter: 'none' }); + + // The handler guard is independent of the disabled DOM attribute. This also + // protects callers that dispatch a click after permission data has changed. + for (const button of await page.locator('#container > .control_bar button').all()) { + await button.evaluate((element) => { + element.removeAttribute('disabled'); + (element as HTMLButtonElement).click(); + }); + } + for (const button of await nationPanel.locator('.control_bar button').all()) { + await button.evaluate((element) => { + element.removeAttribute('disabled'); + (element as HTMLButtonElement).click(); + }); + } + await expect(input).toHaveValue('23456'); - expect(state.mutations).toEqual(['npc.setNationPolicy']); + await expect(inactivePriorityList.getByText('불가침제의')).toBeVisible(); + expect(state.mutations).toEqual([]); + await screenshot(page, 'core-npc-policy-read-only-controls.png'); }); test('a user below secret read permission receives a recoverable page error', async ({ page }) => { diff --git a/app/game-frontend/e2e/selectGeneralLive.spec.ts b/app/game-frontend/e2e/selectGeneralLive.spec.ts index 3e6dd1fc..6b50082f 100644 --- a/app/game-frontend/e2e/selectGeneralLive.spec.ts +++ b/app/game-frontend/e2e/selectGeneralLive.spec.ts @@ -429,6 +429,7 @@ test.describe('scenario 903 live selection pool', () => { expect(created.name).toBe(initialName?.trim()); expect(created.personalCode).toBe('che_안전'); expect(created.specialCode).toMatch(/^che_event_/); + expect(created).toMatchObject({ picture: 'default.jpg', imageServer: 0 }); const createEvent = await db.inputEvent.findFirstOrThrow({ where: { actorUserId: userId, eventType: 'selectPoolCreate' }, orderBy: { sequence: 'desc' }, @@ -508,6 +509,12 @@ test.describe('scenario 903 live selection pool', () => { await expect .poll(async () => (await db.general.findUniqueOrThrow({ where: { id: created.id } })).name) .toBe(targetName); + await expect + .poll(async () => { + const general = await db.general.findUniqueOrThrow({ where: { id: created.id } }); + return { picture: general.picture, imageServer: general.imageServer }; + }) + .toEqual({ picture: 'default.jpg', imageServer: 0 }); const reselectEvent = await db.inputEvent.findFirstOrThrow({ where: { actorUserId: userId, eventType: 'selectPoolReselect' }, orderBy: { sequence: 'desc' }, 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/components/personnel/PermissionMultiSelect.vue b/app/game-frontend/src/components/personnel/PermissionMultiSelect.vue new file mode 100644 index 00000000..05c8b1a2 --- /dev/null +++ b/app/game-frontend/src/components/personnel/PermissionMultiSelect.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index 2045c02d..ff5a5a86 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -64,7 +64,7 @@ const form = ref({ strength: 0, intel: 0, character: 'Random', - pic: true, + pic: false, iconId: undefined, inheritBonusStat: [0, 0, 0], }); @@ -437,6 +437,7 @@ const loadConfig = async () => { } else { form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위'; form.value.iconId = config.user.icons.find((icon) => icon.picture === config.user.preferredPicture)?.id; + form.value.pic = form.value.iconId !== undefined; applyBalancedStats(); } } catch (err) { 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" /> - 자동 외교 (불가침 제의·선전포고) + 자동 외교 (불가침 제의) + +
- +
@@ -498,14 +505,31 @@ const submitPriority = async (section: PrioritySectionKey) => {
- -
- +
@@ -728,10 +752,16 @@ const submitPriority = async (section: PrioritySectionKey) => { border-radius: 4px; } -.control_bar button:hover { +.control_bar button:not(:disabled):hover { filter: brightness(1.15); } +.control_bar button:disabled { + cursor: not-allowed; + opacity: 0.55; + filter: none; +} + .control_bar button:focus-visible, .help-button:focus-visible { outline: 2px solid #fff; diff --git a/app/game-frontend/src/views/SelectGeneralView.vue b/app/game-frontend/src/views/SelectGeneralView.vue index 8ce999b2..37679618 100644 --- a/app/game-frontend/src/views/SelectGeneralView.vue +++ b/app/game-frontend/src/views/SelectGeneralView.vue @@ -429,7 +429,7 @@ onBeforeUnmount(() => { 전콘 선택