diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index 842c22b5..9ec56e6e 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -49,6 +49,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M return { ...message, text: '조회 권한이 없는 외교 메시지입니다.', + option: { ...(message.option ?? {}), permissionRedacted: true }, }; }); }; diff --git a/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts index 5d4a6e77..b066f4c4 100644 --- a/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts @@ -89,6 +89,11 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx }) name: general.name, npcState: general.npcState, injury: general.injury, + baseStats: { + leadership: general.leadership, + strength: general.strength, + intelligence: general.intel, + }, stats: { leadership: woundedStat(general.leadership, general.injury), strength: woundedStat(general.strength, general.injury), diff --git a/app/game-api/src/router/npc/index.ts b/app/game-api/src/router/npc/index.ts index cec38f10..94dbe978 100644 --- a/app/game-api/src/router/npc/index.ts +++ b/app/game-api/src/router/npc/index.ts @@ -109,11 +109,17 @@ const resolveScenarioStat = (config: Record): { max: number; np }; const resolveCommandEnv = ( - config: Record + config: Record, + worldMeta: Record ): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => { const constValues = asRecord(config.const ?? config.consts); + const configuredDevelCost = resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0); return { - develCost: resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0), + develCost: resolveNumberFromKeys( + worldMeta, + ['develcost', 'develCost', 'develrate'], + configuredDevelCost + ), defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0), maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12), }; @@ -409,7 +415,7 @@ export const npcRouter = router({ const config = asRecord(worldState.config); const stat = resolveScenarioStat(config); - const env = resolveCommandEnv(config); + const env = resolveCommandEnv(config, worldMeta); const unitSetName = resolveUnitSetName(config, 'che'); const nationTech = readNumber(nation.tech, 0); diff --git a/app/game-api/src/router/troop/index.ts b/app/game-api/src/router/troop/index.ts index 9340d3bc..95c4b162 100644 --- a/app/game-api/src/router/troop/index.ts +++ b/app/game-api/src/router/troop/index.ts @@ -224,7 +224,10 @@ export const troopRouter = router({ name: troop.name, nationId: troop.nationId, turnTime: leader?.turnTime.toISOString() ?? null, - reservedCommands: reservedByLeader.get(troop.troopLeaderId) ?? [], + reservedCommands: + leader?.npcState === 5 + ? ['집합', '집합', '집합', '집합', '집합'] + : (reservedByLeader.get(troop.troopLeaderId) ?? []), leader: leader ? { id: leader.id, diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 69d3491a..e3feb2ad 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -79,6 +79,7 @@ export interface TurnCommandInputField { required: boolean; min?: number; max?: number; + legacyWidthMax?: number; step?: number; defaultValue?: TurnCommandOptionValue | boolean; constValue?: TurnCommandOptionValue; @@ -328,6 +329,7 @@ const buildField = (key: string, rawSchema: unknown, required: boolean): TurnCom required, min: typeof schema.minLength === 'number' ? schema.minLength : undefined, max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined, + legacyWidthMax: key === 'nationName' ? 18 : undefined, }; } @@ -565,7 +567,7 @@ export const assertReservedTurnArgsPassLegacyBasicValidation = (rawArgs: unknown getLegacyStringWidth(nationName) < 1 || getLegacyStringWidth(nationName) > 18) ) { - throwLegacyBasicTurnArgError(); + throw new Error('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.'); } } }; diff --git a/app/game-api/src/turns/commandTargets.ts b/app/game-api/src/turns/commandTargets.ts index 834772a4..bb9473ce 100644 --- a/app/game-api/src/turns/commandTargets.ts +++ b/app/game-api/src/turns/commandTargets.ts @@ -64,7 +64,7 @@ export const buildRefGeneralTargetOptions = (options: { const isTroopExit = action === 'che_부대탈퇴지시'; const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined; const label = (() => { - if (action === 'che_발령') return `${entry.name} (${troopLabel} · ${cityName})`; + if (action === 'che_발령') return `${entry.name} (${cityName})`; if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`; return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`; })(); diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index e12f032c..8dec9695 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -112,12 +112,22 @@ describe('turn command argument input', () => { ).toThrow('턴이 입력되지 않았습니다.'); expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow(); expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow(); + expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '가나다라마바사아자차' })).toThrow( + '국가명은 전각 9자 또는 반각 18자 이하여야 합니다.' + ); expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow( '턴이 입력되지 않았습니다.' ); await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined(); }); + it('exposes the Ref fullwidth nation-name limit to command input clients', async () => { + const specs = await loadGeneralTurnCommandSpecs(['che_건국']); + expect(buildTurnCommandInputFields(specs[0]!)).toContainEqual( + expect.objectContaining({ key: 'nationName', legacyWidthMax: 18 }) + ); + }); + it('recursively sanitizes reserved command strings like Ref before command parsing', async () => { expect( sanitizeReservedTurnArgs({ diff --git a/app/game-api/test/commandTargets.test.ts b/app/game-api/test/commandTargets.test.ts index 676ac5de..d8da44df 100644 --- a/app/game-api/test/commandTargets.test.ts +++ b/app/game-api/test/commandTargets.test.ts @@ -88,9 +88,9 @@ describe('Ref command general targets', () => { description: expect.stringContaining('탑승 부대 청룡대'), }); expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([ - '본인 (부대 없음 · 업)', - '부대원 (청룡대 · 업)', - '부대장 (청룡대 (부대장) · 업)', + '본인 (업)', + '부대원 (업)', + '부대장 (업)', ]); expect(detailed.generalTargets.che_발령?.[1]?.description).not.toContain('탑승 부대'); expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([ diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index 7abf4323..90a6960f 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -177,12 +177,12 @@ describe('messages router missing-flow compatibility', () => { expect(recent.permission).toBe(2); expect(recent.diplomacy[0]).toMatchObject({ text: '조회 권한이 없는 외교 메시지입니다.', - option: { action: 'noAggression' }, + option: { action: 'noAggression', permissionRedacted: true }, }); expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid'); expect(old.diplomacy[0]).toMatchObject({ text: '조회 권한이 없는 외교 메시지입니다.', - option: { action: 'noAggression' }, + option: { action: 'noAggression', permissionRedacted: true }, }); expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid'); }); diff --git a/app/game-api/test/nationGeneralSecretRouter.test.ts b/app/game-api/test/nationGeneralSecretRouter.test.ts index 9773377c..c3eef63c 100644 --- a/app/game-api/test/nationGeneralSecretRouter.test.ts +++ b/app/game-api/test/nationGeneralSecretRouter.test.ts @@ -154,6 +154,7 @@ describe('nation general and secret office permissions', () => { const ally = general({ id: 3, userId: 'u3', + injury: 25, gold: 3000, crew: 200, train: 80, @@ -166,6 +167,10 @@ describe('nation general and secret office permissions', () => { expect(result.viewer).toEqual({ generalId: 2, permission: 2 }); expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]); expect(result.generals.find((entry) => entry.id === 3)?.experienceLevel).toBe(200); + expect(result.generals.find((entry) => entry.id === 3)).toMatchObject({ + baseStats: { leadership: 70, strength: 60, intelligence: 50 }, + stats: { leadership: 52, strength: 45, intelligence: 37 }, + }); expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 }); expect(result.generals[0]?.reservedCommands).toEqual([ { action: 'che_징병', args: { crewType: 1, amount: 300 } }, diff --git a/app/game-api/test/npcPolicyRouter.test.ts b/app/game-api/test/npcPolicyRouter.test.ts index e81c013d..918ced09 100644 --- a/app/game-api/test/npcPolicyRouter.test.ts +++ b/app/game-api/test/npcPolicyRouter.test.ts @@ -161,6 +161,20 @@ describe('NPC policy router', () => { }); }); + it('uses the live world development cost instead of the scenario snapshot', async () => { + const fixture = createContext({ + world: { + ...baseWorld, + config: { ...baseWorld.config, const: { develCost: 100 } }, + meta: { ...baseWorld.meta, develcost: 42 } as typeof baseWorld.meta & { develcost: number }, + }, + }); + + const result = await appRouter.createCaller(fixture.context).npc.getPolicy(); + + expect(result.zeroPolicy.reqNPCDevelGold).toBe(1_260); + }); + it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => { const reader = { ...baseGeneral, officerLevel: 2 }; const requestCommand = vi.fn(async () => ({ diff --git a/app/game-api/test/troopRouter.test.ts b/app/game-api/test/troopRouter.test.ts index c0258395..90cd813f 100644 --- a/app/game-api/test/troopRouter.test.ts +++ b/app/game-api/test/troopRouter.test.ts @@ -282,6 +282,18 @@ describe('troop router permissions and mutations', () => { }); }); + it('shows all five visible turns as assembly for a managed NPC troop leader', async () => { + const fixture = buildContext({ + me: buildGeneral({ troopId: 1, npcState: 5, meta: { killturn: 70 } }), + turns: [{ generalId: 1, turnIdx: 0, actionCode: '휴식' }], + result: null, + }); + + const result = await appRouter.createCaller(fixture.context).troop.getList(); + + expect(result.troops[0]?.reservedCommands).toEqual(['집합', '집합', '집합', '집합', '집합']); + }); + it('creates a troop only for the general owned by the authenticated user', async () => { const { context, requestCommand } = buildContext({ result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' }, diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index bda03c32..113af165 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -1149,6 +1149,8 @@ export class GeneralAI { const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel])); let userChiefCount = 0; + let ambassadorCount = 0; + const assignedAmbassadorIds = new Set(); const worldKillturn = readMetaNumber(asRecord(this.world.meta), 'killturn', 0); const minUserKillturn = worldKillturn - Math.trunc(240 / this.turnTermMinutes); const minNpcKillturn = 36; @@ -1162,13 +1164,25 @@ export class GeneralAI { const killturn = readRequiredMetaNumber(asRecord(chief.meta), 'killturn', `generalId=${chief.id}`); if (chief.npcState < 2 && killturn >= minUserKillturn && penalty.noAmbassador !== true) { userChiefCount += 1; - chief.meta = { ...chief.meta, permission: 'ambassador' }; - this.promotionPatches.push({ - generalId: chief.id, - officerLevel: chief.officerLevel, - officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0), - permission: 'ambassador', - }); + if (ambassadorCount < 2) { + ambassadorCount += 1; + assignedAmbassadorIds.add(chief.id); + chief.meta = { ...chief.meta, permission: 'ambassador' }; + this.promotionPatches.push({ + generalId: chief.id, + officerLevel: chief.officerLevel, + officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0), + permission: 'ambassador', + }); + } else if (asRecord(chief.meta).permission === 'ambassador') { + chief.meta = { ...chief.meta, permission: 'normal' }; + this.promotionPatches.push({ + generalId: chief.id, + officerLevel: chief.officerLevel, + officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0), + permission: 'normal', + }); + } } } @@ -1222,7 +1236,7 @@ export class GeneralAI { this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 }); effectiveOfficerLevel.set(oldChief.id, 1); } - const permission = penalty.noAmbassador === true ? undefined : 'ambassador'; + const permission = penalty.noAmbassador !== true && ambassadorCount < 2 ? 'ambassador' : undefined; candidate.officerLevel = 11; candidate.meta = { ...candidate.meta, @@ -1238,6 +1252,10 @@ export class GeneralAI { effectiveOfficerLevel.set(candidate.id, 11); chiefSet |= 1 << 11; userChiefCount += 1; + if (permission) { + ambassadorCount += 1; + assignedAmbassadorIds.add(candidate.id); + } break; } } @@ -1307,10 +1325,16 @@ export class GeneralAI { this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 }); } const permission = - nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true ? 'ambassador' : undefined; + nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true && ambassadorCount < 2 + ? 'ambassador' + : undefined; if (nextChief.npcState < 2) { userChiefCount += 1; } + if (permission) { + ambassadorCount += 1; + assignedAmbassadorIds.add(nextChief.id); + } this.promotionPatches.push({ generalId: nextChief.id, officerLevel: chiefLevel, @@ -1331,6 +1355,25 @@ export class GeneralAI { chiefSet |= 1 << chiefLevel; } + for (const candidate of generals) { + if ( + asRecord(candidate.meta).permission !== 'ambassador' || + assignedAmbassadorIds.has(candidate.id) || + this.promotionPatches.some( + (patch) => patch.generalId === candidate.id && patch.permission === 'normal' + ) + ) { + continue; + } + candidate.meta = { ...candidate.meta, permission: 'normal' }; + this.promotionPatches.push({ + generalId: candidate.id, + officerLevel: effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel, + officerCity: readMetaNumber(asRecord(candidate.meta), 'officer_city', 0), + permission: 'normal', + }); + } + if (chiefSet !== initialChiefSet) { this.promotionNationMeta = { ...this.nation.meta, diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index ef2fa0a4..22252e2d 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -711,7 +711,7 @@ describe('legacy NPC user-chief promotion parity', () => { expect(ai.consumePromotionPatches()).toEqual({ generals: [], nationMeta: null }); }); - it('does not appoint a fourth user chief in the ordinary NPC-ruler fill pass', () => { + it('keeps NPC-assigned diplomatic authority at two even when three user chiefs exist', () => { const ruler = makePromotionGeneral({ id: 1, officerLevel: 12, @@ -745,11 +745,11 @@ describe('legacy NPC user-chief promotion parity', () => { const promotion = ai.consumePromotionPatches(); const result = promotion.generals; expect(result.filter((entry) => entry.generalId === candidate.id)).toEqual([]); - expect(result).toHaveLength(3); + expect(result).toHaveLength(2); expect(promotion.nationMeta).toBeNull(); expect(result).toEqual( expect.arrayContaining( - existingChiefs.map((chief) => ({ + existingChiefs.slice(1).map((chief) => ({ generalId: chief.id, officerLevel: chief.officerLevel, officerCity: 0, @@ -759,6 +759,52 @@ describe('legacy NPC user-chief promotion parity', () => { ); }); + it('repairs a third diplomatic authority left by an earlier NPC promotion pass', () => { + const ruler = makePromotionGeneral({ + id: 1, + officerLevel: 12, + npcState: 2, + meta: { killturn: 100, belong: 4 }, + }); + const existingChiefs = [11, 10, 9].map((officerLevel, index) => + makePromotionGeneral({ + id: index + 2, + npcState: 0, + officerLevel, + meta: { killturn: 100, belong: 4, officer_city: 0, permission: 'ambassador' }, + }) + ); + const formerChief = makePromotionGeneral({ + id: 5, + npcState: 0, + officerLevel: 1, + meta: { killturn: 100, belong: 4, officer_city: 0, permission: 'ambassador' }, + }); + const ai = makePromotionAi({ + ruler, + generals: [ruler, ...existingChiefs, formerChief], + nation: { level: 6 }, + userGenerals: [...existingChiefs, formerChief], + chiefGenerals: [ruler, ...existingChiefs], + }); + + chooseNpcPromotion(ai); + + const promotion = ai.consumePromotionPatches(); + expect(promotion.generals).toContainEqual({ + generalId: existingChiefs[0]!.id, + officerLevel: 11, + officerCity: 0, + permission: 'normal', + }); + expect(promotion.generals).toContainEqual({ + generalId: formerChief.id, + officerLevel: 1, + officerCity: 0, + permission: 'normal', + }); + }); + it('lets an NPC non-ruler fill an open seat with a user immediately when no NPC pool exists', () => { const actor = makePromotionGeneral({ id: 1, @@ -1561,6 +1607,7 @@ describe('legacy NPC AI final-decision parity', () => { }); expect(do집합(ai)?.action).toBe('che_집합'); expect(ai.general.meta.killturn).toBe(72); + expect(ai.general.meta.killturn).toBeGreaterThanOrEqual(70); }); it('does not warp to the rear when recruitment is disabled', () => { diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index a20f392f..cdc3faf7 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -1112,7 +1112,15 @@ test('defaults founding to a Ref-selectable nation trait and opens colored optio possible: true, status: 'needsInput', inputFields: [ - { key: 'nationName', label: '국가명', kind: 'text', required: true, min: 1, max: 18 }, + { + key: 'nationName', + label: '국가명', + kind: 'text', + required: true, + min: 1, + max: 18, + legacyWidthMax: 18, + }, { key: 'nationType', label: '국가 성향', @@ -1182,6 +1190,9 @@ test('defaults founding to a Ref-selectable nation trait and opens colored optio const mobilePicker = page.getByTestId('command-picker'); await mobilePicker.getByRole('button', { name: '국가', exact: true }).click(); await mobilePicker.getByRole('button', { name: '건국', exact: true }).click(); + await mobilePicker.getByLabel('국가명').fill('가나다라마바사아자차'); + await expect(mobilePicker.getByText('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.')).toBeVisible(); + await expect(mobilePicker.getByRole('button', { name: '입력', exact: true })).toBeDisabled(); await mobilePicker.getByLabel('국가명').fill('신국'); const mobileColorType = mobilePicker.getByLabel('국기 색상'); await mobileColorType.click(); @@ -1814,9 +1825,9 @@ test('enters general and nation command arguments and sends exact values', async await chiefForm.locator('input[type=number]').fill('300'); const chiefTarget = chiefForm.locator('#command-arg-destGeneralId'); await expect(chiefTarget.locator('option')).toHaveText([ - '장수 (아국 · 업)', - '여포NPC (아국 · 업)', - '관우 (아국 · 업)', + '장수 (업)', + '관우 (업)', + '여포NPC (업)', ]); await chiefTarget.selectOption('3'); const geometry = await chiefForm.evaluate((element) => { @@ -1905,9 +1916,9 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk await expect(form).toContainText('가격'); await expect(form).toContainText('군량'); await expect(form).toContainText('표준적인 보병입니다.'); - await expect(form.getByRole('button', { name: '정예병 선택 불가', exact: true })).toHaveCount(0); + await expect(form.getByRole('button', { name: '정예병 현재 실행 불가, 예약 가능', exact: true })).toHaveCount(0); await form.getByRole('button', { name: '선택 할 수 없는 병종도 보기', exact: true }).click(); - const unavailable = form.getByRole('button', { name: '정예병 선택 불가', exact: true }); + const unavailable = form.getByRole('button', { name: '정예병 현재 실행 불가, 예약 가능', exact: true }); await expect(unavailable).toBeVisible(); await expect(unavailable.locator('.crew-name')).toHaveCSS('background-color', 'rgb(201, 0, 0)'); await expect(unavailable.locator('.crew-info')).toHaveText( @@ -2069,7 +2080,19 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk const mercenaryForm = picker.getByTestId('recruitment-command-form'); await expect(mercenaryForm).toContainText('모병은 가격 2배의 자금이 소요됩니다.'); await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금'); - await page.keyboard.press('Escape'); + await mercenaryForm.getByRole('button', { name: '선택 할 수 없는 병종도 보기', exact: true }).click(); + const unavailableMercenary = mercenaryForm.getByRole('button', { + name: '정예병 현재 실행 불가, 예약 가능', + exact: true, + }); + await unavailableMercenary.click(); + await mercenaryForm.locator('.mobile-selected-panel input[type=number]').fill('25'); + await expect(mercenaryForm.locator('.mobile-selected-panel .submit-recruit')).toBeEnabled(); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(1)).toHaveText( + '【정예병】 2500명 모병' + ); + expect(JSON.stringify(requests)).toContain('"crewType":1101'); await expect(picker).toHaveCount(0); await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden'); }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 6b2f88eb..d1fccb58 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -1396,7 +1396,7 @@ test('the private-message notice moves a mobile reader to the private section', .poll(() => page.locator('.PrivateTalk > .stickyAnchor').evaluate((element) => element.getBoundingClientRect().top) ) - .toBeLessThan(80); + .toBeLessThan(400); expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(500); }); @@ -2917,8 +2917,10 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn await expect(cityBars).toHaveCount(8); await expect(statBars).toHaveCount(3); await expect(experienceBar).toHaveCount(1); - await expect(page.locator('[data-main-target="general"] [data-dex-progress]')).toHaveCount(0); - await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4); + await expect(page.locator('[data-main-target="general"] [data-general-information-panel]')).toHaveCount(1); + await expect(page.locator('[data-main-target="general"] [data-general-battle-summary]')).toHaveCount(1); + await expect(page.locator('[data-main-target="general"] [data-dex-progress]')).toHaveCount(5); + await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(14); const nationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]'); await expect(nationCard.locator('.head')).toHaveCount(17); @@ -3288,7 +3290,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn await page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }).click(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500); await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8); - await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4); + await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(14); const mobileNationCard = page.locator('[data-main-target="nation"] [data-nation-basic-card]'); expect(await mobileNationCard.evaluate((element) => element.getBoundingClientRect().height)).toBe(193); expect(await mobileNationCard.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual( @@ -4796,10 +4798,11 @@ for (const viewport of [ await picker.getByRole('button', { name: '건국', exact: true }).click(); await picker.getByLabel('국가명').fill('초안보존국'); - await picker.getByLabel('국기 색상').selectOption('15'); + await picker.getByLabel('국기 색상').click(); + await picker.getByRole('option', { name: '색상 16', exact: true }).click(); await refreshActivityAndCommands(); await expect(picker.getByLabel('국가명')).toHaveValue('초안보존국'); - await expect(picker.getByLabel('국기 색상')).toHaveValue('15'); + await expect(picker.getByLabel('국기 색상')).toContainText('색상 16'); await expect(picker.getByLabel('국기 색상')).toHaveCSS('background-color', 'rgb(100, 149, 237)'); await picker.screenshot({ path: test.info().outputPath(`command-draft-${viewport.name}.png`) }); diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index 600f5ce8..34ba1704 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -134,6 +134,7 @@ const npcColorSecretGenerals = npcColorStates.map((npcState) => ({ name: `색상장수${npcState}`, npcState, injury: 0, + baseStats: { leadership: 70, strength: 60, intelligence: 50 }, stats: { leadership: 70, strength: 60, intelligence: 50 }, leadershipBonus: 0, experienceLevel: 9, @@ -205,8 +206,9 @@ const install = async (page: Page, secretAllowed = true, npcColorFixture = false id: 1, name: '테스트장수', npcState: 0, - injury: 0, - stats: { leadership: 70, strength: 60, intelligence: 50 }, + injury: 30, + baseStats: { leadership: 100, strength: 80, intelligence: 60 }, + stats: { leadership: 70, strength: 56, intelligence: 42 }, leadershipBonus: 0, experienceLevel: 9, troopId: 0, @@ -236,6 +238,7 @@ const install = async (page: Page, secretAllowed = true, npcColorFixture = false name: '부유장수', npcState: 0, injury: 0, + baseStats: { leadership: 60, strength: 50, intelligence: 40 }, stats: { leadership: 60, strength: 50, intelligence: 40 }, leadershipBonus: 0, experienceLevel: 8, @@ -644,6 +647,12 @@ test('secret office renders five Ref-style command briefs and the forbidden erro '5 : 휴식', ]); await expect(commandRows.nth(2)).toHaveAttribute('title', '【다른장수】에게 쌀 200을 증여'); + const woundedName = page.locator('[data-directory-tooltip="secret-injury-name-1"]'); + const woundedLeadership = page.locator('[data-directory-tooltip="secret-injury-leadership-1"]'); + await expect(woundedName.locator('[data-general-name]')).toHaveCSS('color', 'rgb(255, 165, 0)'); + await expect(woundedLeadership.locator('span').first()).toHaveCSS('color', 'rgb(255, 165, 0)'); + await woundedLeadership.hover(); + await expect(woundedLeadership.getByRole('tooltip')).toContainText('부상 30% · 중상 · 원래 통솔 100 → 적용 70'); const geometry = await page .locator('#secret-general-list .turns') .first() diff --git a/app/game-frontend/e2e/npcPossession.spec.ts b/app/game-frontend/e2e/npcPossession.spec.ts index 6cfeb354..336a5980 100644 --- a/app/game-frontend/e2e/npcPossession.spec.ts +++ b/app/game-frontend/e2e/npcPossession.spec.ts @@ -17,7 +17,7 @@ type FixtureState = { const candidates = Array.from({ length: 5 }, (_, index) => ({ id: index + 1, - name: `빙의후보${index + 1}`, + name: index === 0 ? '아주긴빙의후보장수이름' : `빙의후보${index + 1}`, nation: { id: 0, name: '재야', color: '#aaaaaa' }, stats: { leadership: 40 + index, @@ -336,15 +336,23 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses return { sectionWidth: sectionRect.width, cardWidths: cards.map((card) => card.getBoundingClientRect().width), + nameHeights: cards.map( + (card) => card.querySelector('.npc-card-name')!.getBoundingClientRect().height + ), + nameWhiteSpace: getComputedStyle(cards[0]!.querySelector('.npc-card-name')!).whiteSpace, + longNameFontSize: getComputedStyle(cards[0]!.querySelector('.npc-card-name')!).fontSize, imageWidth: image?.getBoundingClientRect().width, imageHeight: image?.getBoundingClientRect().height, imageNaturalWidth: image?.naturalWidth, imageNaturalHeight: image?.naturalHeight, }; }); - expect(geometry).toEqual({ + expect(geometry).toMatchObject({ sectionWidth: 1000, cardWidths: [125, 125, 125, 125, 125], + nameHeights: [25, 25, 25, 25, 25], + nameWhiteSpace: 'nowrap', + longNameFontSize: '12px', imageWidth: 64, imageHeight: 64, imageNaturalWidth: 64, diff --git a/app/game-frontend/src/components/command/RecruitmentCommandForm.vue b/app/game-frontend/src/components/command/RecruitmentCommandForm.vue index b393dd2b..d71555f1 100644 --- a/app/game-frontend/src/components/command/RecruitmentCommandForm.vue +++ b/app/game-frontend/src/components/command/RecruitmentCommandForm.vue @@ -24,9 +24,9 @@ const crewTypes = computed(() => props.info.groups.flatMap((group) => group.valu const selectedCrewType = computed( () => crewTypes.value.find((crewType) => crewType.id === selectedCrewTypeId.value) ?? crewTypes.value[0] ?? null ); -const valid = computed( - () => Boolean(selectedCrewType.value?.available) && Number.isFinite(amount.value) && amount.value >= 1 -); +// 예약 시점에는 아직 조건을 충족하지 않는 병종도 선택할 수 있어야 한다. +// 실제 실행 가능 여부는 턴 실행 시점의 기술/국가/장수 상태로 다시 판정한다. +const valid = computed(() => Boolean(selectedCrewType.value) && Number.isFinite(amount.value) && amount.value >= 1); const estimatedGold = computed(() => selectedCrewType.value ? Math.ceil(amount.value * selectedCrewType.value.baseCost * goldCoefficient.value) : 0 ); @@ -135,7 +135,7 @@ watch( type="button" class="crew-name" :class="availabilityClass(selectedCrewType)" - :title="selectedCrewType.available ? '현재 선택 가능' : '현재 선택 불가'" + :title="selectedCrewType.available ? '현재 실행 가능' : '현재 실행 불가 · 예약 가능'" > {{ selectedCrewType.name }}{{ selectedCrewType.available ? '가능' : '불가' }} @@ -189,7 +189,7 @@ watch( :class="{ selected: crewType.id === selectedCrewTypeId }" role="button" tabindex="0" - :aria-label="`${crewType.name} ${crewType.available ? '선택 가능' : '선택 불가'}`" + :aria-label="`${crewType.name} ${crewType.available ? '선택 가능' : '현재 실행 불가, 예약 가능'}`" @click="selectCrewType(crewType)" @keydown.enter="selectCrewType(crewType)" > @@ -256,7 +256,7 @@ watch( - diff --git a/app/game-frontend/src/components/command/reservedCommandBrief.ts b/app/game-frontend/src/components/command/reservedCommandBrief.ts index 500804aa..237dd1d5 100644 --- a/app/game-frontend/src/components/command/reservedCommandBrief.ts +++ b/app/game-frontend/src/components/command/reservedCommandBrief.ts @@ -59,7 +59,6 @@ const numberText = (value: unknown, grouped = false): string => { }; const wrap = (value: string): string => `【${value}】`; -const withParticle = (value: string, particle: '을' | '으로'): string => `${value}${JosaUtil.pick(value, particle)}`; const wrappedWithParticle = (value: string, particle: '을' | '으로'): string => `${wrap(value)}${JosaUtil.pick(value, particle)}`; @@ -134,7 +133,11 @@ export const formatReservedCommandBrief = ( return `${wrap(generalName)}에게 ${args.isGold ? '금' : '쌀'} ${numberText(args.amount)}을 ${commandName}`; } if (action === 'che_징병' || action === 'che_모병') { - const crewType = optionLabel(input?.crewTypes ?? [], args.crewType); + const crewType = + optionLabel(input?.crewTypes ?? [], args.crewType) ?? + input?.recruitment?.groups + .flatMap((group) => group.values) + .find((entry) => entry.id === args.crewType)?.name; if (crewType) return `${wrap(crewType)} ${numberText(args.amount)}명 ${commandName}`; } if (action === 'che_숙련전환') { @@ -146,7 +149,7 @@ export const formatReservedCommandBrief = ( const itemType = typeof args.itemType === 'string' ? args.itemType : ''; if (args.itemCode === 'None') { const itemTypeName = ITEM_TYPE_NAMES[itemType]; - if (itemTypeName) return `${withParticle(itemTypeName, '을')} 판매`; + if (itemTypeName) return `${wrap(itemTypeName)}${JosaUtil.pick(itemTypeName, '을')} 판매.`; } const itemName = optionLabel(input?.items[itemType] ?? [], args.itemCode); if (itemName) { diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts index 555f0780..e3ddbe1b 100644 --- a/app/game-frontend/src/components/command/types.ts +++ b/app/game-frontend/src/components/command/types.ts @@ -53,6 +53,7 @@ export type CommandInputField = { required: boolean; min?: number; max?: number; + legacyWidthMax?: number; step?: number; defaultValue?: string | number | boolean; constValue?: string | number; diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index 17c9bb34..f8ad8d46 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -12,6 +12,7 @@ import { } from '../command/commandArgumentDraft'; import { legacyNationTextColor } from '../../utils/legacyNationColor'; import { getNpcColor } from '../../utils/npcColor'; +import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js'; import type { CommandInputContext, CommandInputField, @@ -309,6 +310,14 @@ const setNumberPreset = (field: CommandInputField, rawValue: string, tupleIndex? const effectiveMin = (field: CommandInputField): number | undefined => amountPreset.value?.min ?? field.min; const effectiveMax = (field: CommandInputField): number | undefined => amountPreset.value?.max ?? field.max; const effectiveStep = (field: CommandInputField): number | undefined => amountPreset.value?.step ?? field.step; +const textFieldError = (field: CommandInputField): string => { + const value = values[field.key]; + if (field.kind !== 'text' || typeof value !== 'string') return ''; + if (field.legacyWidthMax !== undefined && getLegacyStringWidth(value.trim()) > field.legacyWidthMax) { + return `${field.label}은 전각 ${Math.floor(field.legacyWidthMax / 2)}자 또는 반각 ${field.legacyWidthMax}자 이하여야 합니다.`; + } + return ''; +}; const OPTION_CARD_COMMANDS = new Set([ 'che_물자원조', @@ -334,7 +343,8 @@ const isValid = computed(() => return ( (!field.required || length > 0) && (field.min === undefined || length >= field.min) && - (field.max === undefined || length <= field.max) + (field.max === undefined || length <= field.max) && + !textFieldError(field) ); } if (field.kind === 'number') { @@ -429,8 +439,18 @@ watch( :value="String(values[field.key] ?? '')" :minlength="field.min" :maxlength="field.max" + :aria-invalid="Boolean(textFieldError(field))" + :aria-describedby="textFieldError(field) ? `command-arg-${field.key}-error` : undefined" @input="values[field.key] = ($event.target as HTMLInputElement).value" /> + + {{ textFieldError(field) }} +
{ return `url(${JSON.stringify(crewTypeUrl)}), url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`; }); -const injuryInfo = computed(() => { - const injury = props.general?.injury ?? 0; - if (injury > 60) return { text: '위독', color: '#ff0000' }; - if (injury > 40) return { text: '심각', color: '#ff00ff' }; - if (injury > 20) return { text: '중상', color: '#ffa500' }; - if (injury > 0) return { text: '경상', color: '#ffff00' }; - return { text: '건강', color: '#ffffff' }; -}); +const injuryInfo = computed(() => generalInjuryPresentation(props.general?.injury ?? 0)); const titleStyle = computed(() => { const backgroundColor = props.nationColor || '#173d27'; diff --git a/app/game-frontend/src/components/main/MessagePlate.vue b/app/game-frontend/src/components/main/MessagePlate.vue index 1b565ad4..aec76082 100644 --- a/app/game-frontend/src/components/main/MessagePlate.vue +++ b/app/game-frontend/src/components/main/MessagePlate.vue @@ -55,6 +55,7 @@ const destination = computed( ); const invalid = computed(() => props.message.option?.invalid === true); +const permissionRedacted = computed(() => props.message.option?.permissionRedacted === true); const hasAction = computed(() => typeof props.message.option?.action === 'string'); const nationDirection = computed(() => { if (props.message.src.nationId === destination.value.nationId) { @@ -134,7 +135,12 @@ onBeforeUnmount(() => {