From c6459a64dda60088e1074ade95c692b4d451f8f4 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 08:33:57 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=B6=94=EB=B0=A9=20=EB=B3=B4?= =?UTF-8?q?=ED=98=B8=20=EB=8C=80=EC=83=81=EC=9D=84=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=B0=A8=EB=8B=A8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인증된 실행자와 현재 world의 대상을 기준으로 본인, 군주, 수뇌, 외교권자를 mutation 전에 거부한다. 인사부 후보와 엔진 무변경 회귀, 실제 Chromium 후보 검증을 함께 추가한다. --- .../src/turn/worldCommandHandler.ts | 15 +++++++-- .../test/nationPersonnelManagement.test.ts | 33 +++++++++++++++++++ app/game-frontend/e2e/nationOffices.spec.ts | 7 ++++ .../src/views/NationPersonnelView.vue | 4 ++- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 70b12f13..45095881 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -1997,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, @@ -2005,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/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/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/views/NationPersonnelView.vue b/app/game-frontend/src/views/NationPersonnelView.vue index 74d405c8..7a3af80d 100644 --- a/app/game-frontend/src/views/NationPersonnelView.vue +++ b/app/game-frontend/src/views/NationPersonnelView.vue @@ -99,7 +99,9 @@ const cityCandidates = (level: OfficerLevel): GeneralEntry[] => { return candidates; }; const kickCandidates = computed(() => - (data.value?.generals ?? []).filter((general) => general.id !== data.value?.me.id) + (data.value?.generals ?? []).filter( + (general) => general.id !== data.value?.me.id && general.officerLevel < 5 && general.permission !== 'ambassador' + ) ); const awardText = (entries: PersonnelResponse['awards']['tigers']): string => entries.map((entry) => `${entry.name}【${entry.value.toLocaleString('ko-KR')}】`).join(', '); From 7596c6bdc65786bbc36ff022167f54c25ae59c9d Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 08:35:02 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(game):=20=EC=9E=90=EC=9B=90=20=EC=88=98?= =?UTF-8?q?=EC=9E=85=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20=EC=A0=95=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=98=EC=98=AC=EB=A6=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-engine/src/turn/incomeHandler.ts | 5 ++++- app/game-engine/test/monthlyCoreEventAction.test.ts | 7 +++++++ app/game-frontend/e2e/legacyLogHtml.spec.ts | 8 +++++++- 3 files changed, 18 insertions(+), 2 deletions(-) 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/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-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(' 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 01a09ffd..81480b0f 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/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/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 11df30cd..d25a847e 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -257,6 +257,7 @@ 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 }); if (!initialAccess.lastRefresh) { throw new Error('selected general must have an initial access timestamp'); } @@ -268,7 +269,8 @@ integration('scenario 903 select pool through the durable turn daemon', () => { id: initial.id, userId, name: initial.name, - imageServer: initial.imageServer, + imageServer: 0, + picture: 'default.jpg', stats: { leadership: initial.leadership, strength: initial.strength, @@ -380,15 +382,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-engine/src/turn/selectPoolService.ts b/app/game-engine/src/turn/selectPoolService.ts index ceb255ba..8079f245 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), @@ -755,11 +767,14 @@ export const createGeneralFromSelectionPool = async (options: { 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; + // 후보 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 = @@ -1022,6 +1037,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 +1051,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/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/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index ea13a384..32653a0c 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -1981,6 +1981,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/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/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/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(() => { 전콘 선택