From f74c3322b789731ad782eabae9d6fc22f7d44703 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 23 Sep 2026 15:24:31 +0000 Subject: [PATCH] =?UTF-8?q?=EA=B5=AD=EA=B0=80=20=EB=8C=80=EC=83=81=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=20=EC=9E=85=EB=A0=A5=EC=97=90=20Ref=20?= =?UTF-8?q?=EA=B6=8C=EC=9C=A0=EB=AC=B8=C2=B7=EC=9B=90=EC=A1=B0=20=ED=95=9C?= =?UTF-8?q?=EB=8F=84=C2=B7=ED=94=BC=EC=9E=A5=ED=8C=8C=EC=9E=A5=20=EC=A0=84?= =?UTF-8?q?=EB=9E=B5=20=EB=8C=80=EA=B8=B0=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 임관·장수대상임관: 정제된 임관 권유문 목록(크게/작게 보기, 940px 기준 2열)과 임관 대상 국가별 가능 여부(AllowJoinDestNation)를 명령표에 싣는다. - 물자원조: 작위별 원조 한도표를 보이고 현재 작위를 굵은 밑줄로 표시한다. - 피장파장: 전략 선택지를 원본 key 대신 전략 이름으로 보이고, 재사용 대기 중인 전략은 `(불가, N턴)` 붉은색으로 표시한다. 선포·전쟁 국가만 대상 가능으로 둔다. - 이호경식·급습: Ref 외교 조건으로 대상 국가 가능 여부를 계산한다. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/game-api/src/router/turns/index.ts | 19 ++ app/game-api/src/turns/commandInput.ts | 26 +- app/game-api/src/turns/commandTable.ts | 13 + app/game-api/src/turns/commandTargets.ts | 103 ++++++- app/game-api/test/commandInput.test.ts | 18 ++ app/game-api/test/commandTargets.test.ts | 83 ++++++ .../e2e/commandArguments.spec.ts | 267 +++++++++++++++++- .../command/commandArgumentPresentation.ts | 7 +- .../command/commandNationDetails.ts | 58 ++++ .../src/components/command/types.ts | 11 + .../components/main/CommandArgumentForm.vue | 244 +++++++++++++++- .../test/commandNationDetails.test.ts | 61 ++++ 12 files changed, 893 insertions(+), 17 deletions(-) create mode 100644 app/game-frontend/src/components/command/commandNationDetails.ts create mode 100644 app/game-frontend/test/commandNationDetails.test.ts diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index ed842b57..b315126b 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -11,6 +11,7 @@ import { buildRecruitmentCommandInfo, buildTurnCommandTable, evaluateReservedTurnPermission, + resolveCommandJoinEnv, } from '../../turns/commandTable.js'; import { loadMapDefinitionByName } from '../../maps/mapDefinition.js'; import { @@ -45,7 +46,10 @@ import { buildRefAmountPresets, buildRefGeneralTargetOptions, buildRefNationTargetOptions, + COUNTER_STRATEGY_COMMAND_KEYS, } from '../../turns/commandTargets.js'; +import { resolveImpossibleStrategicCommands } from '../../services/mainNationProjection.js'; +import { resolveNationBlockScout, resolveNationScoutMessage } from '../nation/shared.js'; const zPushAmount = z .number() @@ -357,10 +361,20 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number if (adjacentNationId && adjacentNationId !== general.nationId) adjacentNationIds.add(adjacentNationId); } } + // Ref che_피장파장 availableCommandTypeList: 아국 nation_env next_execute_<전략명> 기준 남은 턴. + const strategyCooldowns = Object.fromEntries( + resolveImpossibleStrategicCommands(nation?.meta, worldState.currentYear, worldState.currentMonth).map( + (entry) => [`che_${entry.name}`, entry.remainingTurns] + ) + ); const nationTargetOptions = buildRefNationTargetOptions({ actorNationId: general.nationId, + join: { actorNpcState: general.npcState, ...resolveCommandJoinEnv(worldState) }, + strategyAvailable: COUNTER_STRATEGY_COMMAND_KEYS.some((key) => strategyCooldowns[key] === undefined), nations: nations.map((entry) => { const relation = diplomacyByNation.get(entry.id); + const meta = asRecord(entry.meta); + const gennum = readGeneralMetaNumber(meta, 'gennum'); return { id: entry.id, name: entry.name, @@ -374,6 +388,9 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number diplomacyTerm: relation?.term ?? 0, adjacent: adjacentNationIds.has(entry.id), diplomacyRestricted: (readGeneralMetaNumber(entry.meta, 'surlimit') ?? 0) !== 0, + ...(gennum === null ? {} : { gennum }), + blockScout: resolveNationBlockScout(meta), + scoutMessage: resolveNationScoutMessage(meta), }; }), }); @@ -406,6 +423,8 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number })), nations: nationTargetOptions.nations, nationTargets: nationTargetOptions.nationTargets, + nationScoutMessages: nationTargetOptions.nationScoutMessages, + strategyCooldowns, generals: generalTargetOptions.generals, generalTargets: generalTargetOptions.generalTargets, crewTypes: (environment.unitSet.crewTypes ?? []) diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index a85ca121..7a536bed 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -99,10 +99,21 @@ export interface TurnCommandInputField { tupleLabels?: string[]; } +/** 임관 권유문. message는 서버에서 purifyNationHtml로 정제한 HTML이다. */ +export interface TurnCommandNationScoutMessage { + nationId: number; + name: string; + color: string; + message: string; +} + export interface TurnCommandInputOptions { cities: TurnCommandOption[]; nations: TurnCommandOption[]; nationTargets?: Record; + nationScoutMessages?: TurnCommandNationScoutMessage[]; + /** 아국 전략 명령별 남은 재사용 대기 턴. 대기 중인 전략만 담는다. */ + strategyCooldowns?: Record; generals: TurnCommandOption[]; generalTargets?: Record; crewTypes: TurnCommandOption[]; @@ -242,7 +253,8 @@ const FIELD_LABELS: Record = { optionText: '행동', year: '기간(년)', month: '기간(월)', - commandType: '대응 명령', + // 피장파장 전용. Ref che_피장파장.vue의 '전략 :'. + commandType: '전략', amountList: '지원 물자', }; @@ -268,10 +280,14 @@ const STATIC_LABELS: Record> = { item: '도구', }, commandType: { - che_선전포고: '선전포고', - che_불가침제의: '불가침 제의', - che_불가침파기제의: '불가침 파기 제의', - che_종전제의: '종전 제의', + // 피장파장 대상 전략. Ref availableCommandTypeList의 name(getName()). + che_필사즉생: '필사즉생', + che_백성동원: '백성동원', + che_수몰: '수몰', + che_허보: '허보', + che_의병모집: '의병모집', + che_이호경식: '이호경식', + che_급습: '급습', }, }; diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index d5217ee0..b05b6fc3 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -479,6 +479,19 @@ const mapCityRow = (row: CityRow): City => { }; }; +/** 명령표의 임관 대상 표시에 쓰는 Ref AllowJoinDestNation 시점 값. */ +export const resolveCommandJoinEnv = ( + worldState: WorldStateRow +): { relYear: number; openingPartYear: number; initialNationGenLimit: number } => { + const constValues = asRecord(asRecord(worldState.config).const); + const env = buildConstraintEnv(worldState); + return { + relYear: Number(env.relYear), + openingPartYear: Number(env.openingPartYear), + initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0), + }; +}; + const mapNationRow = (row: NationRow): Nation => ({ id: row.id, name: row.name, diff --git a/app/game-api/src/turns/commandTargets.ts b/app/game-api/src/turns/commandTargets.ts index 9737a0a0..a74a483e 100644 --- a/app/game-api/src/turns/commandTargets.ts +++ b/app/game-api/src/turns/commandTargets.ts @@ -1,6 +1,6 @@ import { DIPLOMACY_STATE } from '@sammo-ts/logic'; -import type { TurnCommandAmountPreset, TurnCommandOption } from './commandInput.js'; +import type { TurnCommandAmountPreset, TurnCommandNationScoutMessage, TurnCommandOption } from './commandInput.js'; export interface GeneralTargetSource { id: number; @@ -33,6 +33,20 @@ export interface NationTargetSource { diplomacyTerm: number; adjacent: boolean; diplomacyRestricted?: boolean; + /** Ref nation.gennum. 없으면 generalCount를 쓴다. */ + gennum?: number; + /** 임관 금지(nation.scout). */ + blockScout?: boolean; + /** 서버에서 purifyNationHtml로 정제한 임관 권유문. */ + scoutMessage?: string; +} + +/** Ref AllowJoinDestNation이 쓰는 actor·시점 정보. */ +export interface NationJoinContext { + actorNpcState: number; + relYear: number; + openingPartYear: number; + initialNationGenLimit: number; } export interface RefGeneralTargetOptions { @@ -43,6 +57,7 @@ export interface RefGeneralTargetOptions { export interface RefNationTargetOptions { nations: TurnCommandOption[]; nationTargets: Record; + nationScoutMessages: TurnCommandNationScoutMessage[]; } const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const; @@ -197,14 +212,63 @@ const NATION_TARGET_COMMANDS = [ 'che_선전포고', 'che_종전제의', 'che_불가침파기제의', + 'che_피장파장', + 'che_이호경식', + 'che_급습', ] as const; -const nationAvailability = ( - action: (typeof NATION_TARGET_COMMANDS)[number], +/** 피장파장으로 지정할 수 있는 전략(GameConst availableChiefCommand['전략']에서 자신 제외). */ +export const COUNTER_STRATEGY_COMMAND_KEYS = [ + 'che_필사즉생', + 'che_백성동원', + 'che_수몰', + 'che_허보', + 'che_의병모집', + 'che_이호경식', + 'che_급습', +] as const; + +/** Ref che_임관 exportJSVars()의 notAvailable: AllowJoinDestNation의 대상 국가 조건. */ +const joinAvailability = ( + join: NationJoinContext, actorNationId: number, target: NationTargetSource ): { available: boolean; reason: string } => { if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' }; + const gennum = target.gennum ?? target.generalCount; + if (join.relYear < join.openingPartYear && join.initialNationGenLimit > 0 && gennum >= join.initialNationGenLimit) { + return { available: false, reason: '임관이 제한되고 있습니다.' }; + } + if (target.blockScout) return { available: false, reason: '임관이 금지되어 있습니다.' }; + if (join.actorNpcState < 2 && target.name.startsWith('ⓤ')) { + return { available: false, reason: '유저장은 태수국에 임관할 수 없습니다.' }; + } + if (join.actorNpcState !== 9 && target.name.startsWith('ⓞ')) { + return { available: false, reason: '이민족 국가에 임관할 수 없습니다.' }; + } + return { available: true, reason: '현재 임관 가능' }; +}; + +const nationAvailability = ( + action: (typeof NATION_TARGET_COMMANDS)[number], + actorNationId: number, + target: NationTargetSource, + strategyAvailable: boolean +): { available: boolean; reason: string } => { + if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' }; + // Ref 피장파장은 쓸 수 있는 전략이 하나도 없으면 모든 국가를 불가로 표시한다. + if (action === 'che_피장파장' && !strategyAvailable) { + return { available: false, reason: '사용할 수 있는 전략이 없습니다.' }; + } + if (action === 'che_피장파장' || action === 'che_이호경식') { + const available = [DIPLOMACY_STATE.WAR, DIPLOMACY_STATE.DECLARATION].includes(target.diplomacyState as 0 | 1); + return { available, reason: available ? '현재 발동 가능' : '선포, 전쟁중인 상대국에게만 가능합니다.' }; + } + if (action === 'che_급습') { + // Ref AllowDiplomacyWithTerm(1, 12): 선포 상태이고 남은 기간이 12개월 이상. + const available = target.diplomacyState === DIPLOMACY_STATE.DECLARATION && target.diplomacyTerm >= 12; + return { available, reason: available ? '현재 발동 가능' : '선포 12개월 이상인 상대국에만 가능합니다.' }; + } if (action === 'che_물자원조') { return target.diplomacyRestricted ? { available: false, reason: '상대국이 외교제한 중입니다.' } @@ -233,7 +297,12 @@ const nationAvailability = ( export const buildRefNationTargetOptions = (options: { actorNationId: number; nations: readonly NationTargetSource[]; + /** 없으면 임관 대상 가능 여부를 계산하지 않고 기본 국가 목록을 쓴다. */ + join?: NationJoinContext; + /** 피장파장으로 지정할 수 있는 전략이 하나라도 있는지. 기본값 true. */ + strategyAvailable?: boolean; }): RefNationTargetOptions => { + const strategyAvailable = options.strategyAvailable ?? true; const baseOptions = options.nations.map((entry) => ({ value: entry.id, label: entry.name, @@ -245,7 +314,7 @@ export const buildRefNationTargetOptions = (options: { for (const action of NATION_TARGET_COMMANDS) { nationTargets[action] = options.nations .map((entry) => { - const availability = nationAvailability(action, options.actorNationId, entry); + const availability = nationAvailability(action, options.actorNationId, entry, strategyAvailable); const relation = DIPLOMACY_LABELS[entry.diplomacyState] ?? `관계 ${entry.diplomacyState}`; const term = entry.diplomacyTerm > 0 ? ` ${entry.diplomacyTerm}턴` : ''; return { @@ -266,7 +335,31 @@ export const buildRefNationTargetOptions = (options: { ) .map(({ power: _power, ...entry }) => entry); } - return { nations: baseOptions, nationTargets }; + const join = options.join; + if (join) { + // Ref 임관 목록은 nation 표 순서 그대로이며 불가 국가도 남긴다. 재야(0)는 국가가 아니다. + nationTargets.che_임관 = options.nations.flatMap((entry, index) => { + if (entry.id <= 0) return []; + const availability = joinAvailability(join, options.actorNationId, entry); + return [ + { + ...baseOptions[index]!, + availableNow: availability.available, + description: `${availability.reason} · ${baseOptions[index]!.description}`, + }, + ]; + }); + } + // Ref che_임관·che_장수대상임관은 모든 국가의 권유문(scout_msg)을 함께 내보낸다. + const nationScoutMessages = options.nations + .filter((entry) => entry.id > 0) + .map((entry) => ({ + nationId: entry.id, + name: entry.name, + color: entry.color, + message: entry.scoutMessage ?? '', + })); + return { nations: baseOptions, nationTargets, nationScoutMessages }; }; const RESOURCE_ACTION_GUIDE = [ diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index ffa02c75..92ac1c19 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -53,6 +53,24 @@ describe('turn command argument input', () => { { key: 'destNationId', kind: 'select', optionSource: 'nations' }, { key: 'amountList', kind: 'numberTuple' }, ]); + // Ref che_피장파장.vue: '전략 :' select에 자신을 뺀 전략 이름을 보인다. + expect(fields.find((entry) => entry.key === 'che_피장파장')?.fields).toMatchObject([ + { key: 'destNationId', kind: 'select', optionSource: 'nations' }, + { + key: 'commandType', + label: '전략', + kind: 'select', + options: [ + { value: 'che_필사즉생', label: '필사즉생' }, + { value: 'che_백성동원', label: '백성동원' }, + { value: 'che_수몰', label: '수몰' }, + { value: 'che_허보', label: '허보' }, + { value: 'che_의병모집', label: '의병모집' }, + { value: 'che_이호경식', label: '이호경식' }, + { value: 'che_급습', label: '급습' }, + ], + }, + ]); expect( nationFields diff --git a/app/game-api/test/commandTargets.test.ts b/app/game-api/test/commandTargets.test.ts index 3881b3a9..802c8172 100644 --- a/app/game-api/test/commandTargets.test.ts +++ b/app/game-api/test/commandTargets.test.ts @@ -5,6 +5,7 @@ import { buildRefGeneralTargetOptions, buildRefNationTargetOptions, type GeneralTargetSource, + type NationTargetSource, } from '../src/turns/commandTargets.js'; const general = (overrides: Partial): GeneralTargetSource => ({ @@ -323,6 +324,88 @@ describe('Ref nation target guidance', () => { expect(result.nationTargets.che_불가침파기제의?.[0]?.description).toContain('불가침 12턴'); expect(result.nationTargets.che_불가침파기제의?.at(-1)).toMatchObject({ value: 4, availableNow: false }); }); + + it('marks counter-strategy targets like Ref exportJSVars notAvailable', () => { + const withDeclaration = [ + ...nations, + { ...nations[3]!, id: 5, name: '선포국', diplomacyState: 1, diplomacyTerm: 12 }, + { ...nations[3]!, id: 6, name: '신규선포국', diplomacyState: 1, diplomacyTerm: 11 }, + ]; + const result = buildRefNationTargetOptions({ actorNationId: 1, nations: withDeclaration }); + const available = (action: string) => + result.nationTargets[action]?.filter((entry) => entry.availableNow).map((entry) => entry.value); + // 피장파장·이호경식: AllowDiplomacyBetweenStatus([0, 1]) + expect(available('che_피장파장')).toEqual([4, 5, 6]); + expect(available('che_이호경식')).toEqual([4, 5, 6]); + // 급습: AllowDiplomacyWithTerm(1, 12) + expect(available('che_급습')).toEqual([5]); + expect(result.nationTargets.che_급습?.find((entry) => entry.value === 6)?.description).toContain( + '선포 12개월 이상인 상대국에만 가능합니다.' + ); + expect(result.nationTargets.che_피장파장?.find((entry) => entry.value === 1)).toMatchObject({ + availableNow: false, + }); + + const exhausted = buildRefNationTargetOptions({ + actorNationId: 1, + nations: withDeclaration, + strategyAvailable: false, + }); + expect(exhausted.nationTargets.che_피장파장?.every((entry) => entry.availableNow === false)).toBe(true); + expect(exhausted.nationTargets.che_피장파장?.find((entry) => entry.value === 4)?.description).toContain( + '사용할 수 있는 전략이 없습니다.' + ); + expect(exhausted.nationTargets.che_이호경식?.some((entry) => entry.availableNow)).toBe(true); + }); + + it('keeps the Ref nation order for appointment targets and exports purified scout messages', () => { + const joinNations: NationTargetSource[] = [ + { ...nations[0]!, scoutMessage: '

어서 오시오

' }, + { ...nations[1]!, blockScout: true }, + { ...nations[2]!, name: 'ⓤ태수국' }, + { ...nations[3]!, name: 'ⓞ이민족' }, + { ...nations[3]!, id: 5, name: '초반만원국', generalCount: 3, gennum: 10 }, + ]; + const join = { actorNpcState: 0, relYear: 1, openingPartYear: 3, initialNationGenLimit: 10 }; + const result = buildRefNationTargetOptions({ actorNationId: 0, nations: joinNations, join }); + expect(result.nationTargets.che_임관?.map((entry) => [entry.value, entry.availableNow])).toEqual([ + [1, true], + [2, false], + [3, false], + [4, false], + [5, false], + ]); + expect(result.nationTargets.che_임관?.map((entry) => entry.description?.split(' · ')[0])).toEqual([ + '현재 임관 가능', + '임관이 금지되어 있습니다.', + '유저장은 태수국에 임관할 수 없습니다.', + '이민족 국가에 임관할 수 없습니다.', + '임관이 제한되고 있습니다.', + ]); + // 초반 제한이 끝나면 인원 제한은 없고, NPC(9)는 이민족 국가에 임관할 수 있다. + const later = buildRefNationTargetOptions({ + actorNationId: 0, + nations: joinNations, + join: { ...join, actorNpcState: 9, relYear: 3 }, + }); + expect(later.nationTargets.che_임관?.map((entry) => entry.availableNow)).toEqual([ + true, + false, + true, + true, + true, + ]); + expect(result.nationScoutMessages).toEqual( + joinNations.map((entry) => ({ + nationId: entry.id, + name: entry.name, + color: entry.color, + message: entry.scoutMessage ?? '', + })) + ); + // join 정보가 없으면 임관은 기존 공통 국가 목록을 그대로 쓴다. + expect(buildRefNationTargetOptions({ actorNationId: 0, nations }).nationTargets.che_임관).toBeUndefined(); + }); }); describe('Ref amount presets', () => { diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index e9239d53..bc952b04 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -1,6 +1,11 @@ import { expect, test, type Page, type Route } from '@playwright/test'; import { readFile, writeFile } from 'node:fs/promises'; -import { buildRefGeneralTargetOptions } from '../../game-api/src/turns/commandTargets.js'; +import { + buildRefGeneralTargetOptions, + buildRefNationTargetOptions, + type NationTargetSource, +} from '../../game-api/src/turns/commandTargets.js'; +import { purifyNationHtml } from '../../game-api/src/security/nationHtml.js'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gamePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js'; @@ -4331,3 +4336,263 @@ for (const width of [1200, 390]) { await expect.poll(() => JSON.stringify(requests)).toContain('"action":"che_등용","args":{"destGeneralId":5}'); }); } + +// 2-3 국가 대상: Ref che_임관·che_장수대상임관 권유문 목록, che_물자원조 작위별 한도표, che_피장파장 전략 재사용 대기. +const nationTargetSources: NationTargetSource[] = [ + { + id: 1, + name: '아국', + color: '#008000', + capitalName: '업', + level: 1, + power: 1000, + generalCount: 5, + cityCount: 2, + diplomacyState: 2, + diplomacyTerm: 0, + adjacent: false, + scoutMessage: purifyNationHtml('

아국 권유문

'), + }, + { + id: 2, + name: '위', + color: '#0000FF', + capitalName: '허창', + level: 2, + power: 800, + generalCount: 4, + cityCount: 2, + diplomacyState: 0, + diplomacyTerm: 6, + adjacent: true, + // 저장·조회 경로와 같은 정제 결과만 화면에 전달한다. script와 on* 속성은 남지 않는다. + scoutMessage: purifyNationHtml( + '
천하를 함께' + + '함께
' + ), + }, + { + id: 3, + name: '오', + color: '#FFFF00', + capitalName: '건업', + level: 2, + power: 900, + generalCount: 3, + cityCount: 1, + diplomacyState: 1, + diplomacyTerm: 12, + adjacent: false, + blockScout: true, + }, +]; + +for (const width of [1200, 390]) { + test(`shows Ref scout messages for appointment commands at ${width}px`, async ({ page }, testInfo) => { + const nationTargets = buildRefNationTargetOptions({ + actorNationId: 0, + nations: nationTargetSources, + join: { actorNpcState: 0, relYear: 5, openingPartYear: 3, initialNationGenLimit: 10 }, + }); + const requests = await install(page, false, { + ...commandTable, + general: [ + { + category: '인사', + values: [ + { + ...buildNationCommand('che_임관', '임관'), + }, + buildGeneralCommand('che_장수대상임관', '장수대상임관'), + ], + }, + ], + inputOptions: { + ...inputOptions, + nations: nationTargets.nations, + nationTargets: { ...inputOptions.nationTargets, ...nationTargets.nationTargets }, + nationScoutMessages: nationTargets.nationScoutMessages, + }, + }); + await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); + await page.goto(gamePath('/')); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: /^임관/ }).click(); + const form = picker.getByTestId('command-argument-form'); + const list = form.getByTestId('nation-scout-list'); + const rows = list.getByTestId('nation-scout-row'); + await expect(list.locator('.nation-scout-header > div')).toHaveText(['국가명', '임관권유문']); + await expect(rows.locator('.nation-scout-name')).toHaveText(['아국', '위', '오']); + await expect(rows.nth(1).locator('.nation-scout-msg b')).toHaveText('천하를 함께'); + expect(await list.locator('script, [onclick]').count()).toBe(0); + expect(await page.evaluate(() => (window as unknown as { __scoutXss?: number }).__scoutXss)).toBeUndefined(); + // 국가명 칸은 국가색 배경이고 Ref isBrightColor 기준으로 글자색을 고른다. + const nameStyles = await rows + .locator('.nation-scout-name') + .evaluateAll((nodes) => + nodes.map((node) => [getComputedStyle(node).backgroundColor, getComputedStyle(node).color]) + ); + expect(nameStyles).toEqual([ + ['rgb(0, 128, 0)', 'rgb(255, 255, 255)'], + ['rgb(0, 0, 255)', 'rgb(255, 255, 255)'], + ['rgb(255, 255, 0)', 'rgb(0, 0, 0)'], + ]); + // 임관 금지 국가는 대상 카드에서 현재 불가로 남는다. + await expect(form.getByTestId('nation-target-list').locator('.target-option').nth(2)).toContainText( + '임관이 금지되어 있습니다.' + ); + + const toggle = list.getByRole('button', { name: /보기$/ }); + const plate = rows.nth(1).locator('.nation-scout-plate'); + if (width >= 940) { + // Ref media-1000px(940px 이상): 130px + 870px 두 열, 크게/작게 보기 버튼 없음. + await expect(toggle).toBeHidden(); + await expect(rows.nth(1)).toHaveCSS('grid-template-columns', '130px 870px'); + } else { + await expect(toggle).toHaveText('작게 보기'); + await expect(toggle).toHaveAttribute('aria-pressed', 'true'); + await expect(plate).toHaveCSS('max-height', '200px'); + await expect(plate).toHaveCSS('overflow-x', 'auto'); + await toggle.click(); + await expect(toggle).toHaveText('크게 보기'); + await expect(rows.nth(1)).toHaveClass(/on-fit/); + } + await rows.nth(1).click(); + await expect(form.locator('#command-arg-destNationId')).toHaveValue('2'); + await expect(rows.nth(1)).toHaveClass(/selected/); + + await page.evaluate(() => document.fonts.ready); + const geometry = await picker.evaluate((element) => { + const row = element.querySelectorAll('[data-testid=nation-scout-row]')[1]!; + const message = row.querySelector('.nation-scout-msg')!; + return { + url: location.href, + viewport: [innerWidth, innerHeight], + overlayOverflow: element.scrollWidth - element.clientWidth, + list: element.querySelector('[data-testid=nation-scout-list]')?.getBoundingClientRect().toJSON(), + row: row.getBoundingClientRect().toJSON(), + plate: row.querySelector('.nation-scout-plate')!.getBoundingClientRect().toJSON(), + message: message.getBoundingClientRect().toJSON(), + transform: getComputedStyle(message).transform, + }; + }); + expect(geometry.overlayOverflow).toBe(0); + if (width < 940) { + // on-fit: 870px 권유문을 목록 폭/870으로 줄이고, 높이는 200px × 같은 비율까지만 보인다. + const scale = geometry.list!.width / 870; + expect(geometry.message.width).toBeCloseTo(870 * scale, 0); + expect(geometry.plate.height).toBeLessThanOrEqual(200 * scale + 1); + expect(geometry.plate.right).toBeLessThanOrEqual(width); + } else { + expect(geometry.plate.width).toBeCloseTo(870, 0); + expect(geometry.transform).toBe('none'); + } + await writeFile(testInfo.outputPath(`scout-list-${width}.json`), JSON.stringify(geometry, null, 2)); + await picker.screenshot({ path: testInfo.outputPath(`scout-list-${width}.png`) }); + await picker.getByRole('button', { name: '임관 입력', exact: true }).click(); + await expect.poll(() => JSON.stringify(requests)).toContain('"action":"che_임관","args":{"destNationId":2}'); + + // 장수대상임관은 재야를 앞에 둔 같은 목록을 보이며 행을 눌러도 국가를 고르지 않는다. + await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); + await picker.getByRole('button', { name: /장수대상임관/ }).click(); + await expect(rows.locator('.nation-scout-name')).toHaveText(['재야', '아국', '위', '오']); + await expect(rows.first().locator('.nation-scout-name')).toHaveCSS('background-color', 'rgb(0, 0, 0)'); + await expect(rows.first().locator('.nation-scout-name button')).toHaveCount(0); + }); + + test(`shows Ref aid limits and counter-strategy cooldowns at ${width}px`, async ({ page }, testInfo) => { + const nationTargets = buildRefNationTargetOptions({ actorNationId: 1, nations: nationTargetSources }); + const counterStrategy = { + ...buildNationCommand('che_피장파장', '피장파장'), + inputFields: [ + ...buildNationCommand('che_피장파장', '피장파장').inputFields, + { + key: 'commandType', + label: '전략', + kind: 'select', + required: true, + options: [ + { value: 'che_필사즉생', label: '필사즉생' }, + { value: 'che_수몰', label: '수몰' }, + { value: 'che_허보', label: '허보' }, + ], + }, + ], + }; + const aid = commandTable.nation.flatMap((group) => group.values).find((entry) => entry.key === 'che_물자원조')!; + const requests = await install(page, false, { + ...commandTable, + nation: [{ category: '전략', values: [counterStrategy, aid] }], + inputOptions: { + ...inputOptions, + nations: nationTargets.nations, + nationTargets: { ...inputOptions.nationTargets, ...nationTargets.nationTargets }, + strategyCooldowns: { che_수몰: 12 }, + context: { ...inputOptions.context, nationLevel: 3 }, + }, + }); + await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); + await page.goto(gamePath('/chief-center')); + const editor = page.locator('[data-command-scope="nation"]'); + await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: /^(?:국가:)?전략$/, exact: true }).click(); + await picker.getByRole('button', { name: /피장파장/ }).click(); + const form = picker.getByTestId('command-argument-form'); + const strategy = form.locator('#command-arg-commandType'); + await expect(strategy.locator('option')).toHaveText(['필사즉생', '수몰 (불가, 12턴)', '허보']); + await expect(strategy.locator('option').nth(1)).toHaveCSS('color', 'rgb(255, 0, 0)'); + await strategy.selectOption('che_수몰'); + await expect(strategy).toHaveCSS('color', 'rgb(255, 0, 0)'); + await strategy.selectOption('che_허보'); + await expect(strategy).not.toHaveCSS('color', 'rgb(255, 0, 0)'); + // 선포·전쟁 중인 국가만 가능하고 아국은 불가다. + const cards = form.getByTestId('nation-target-list').locator('.target-option'); + await expect(cards.locator('strong')).toHaveText(['오', '위', '아국']); + await expect(cards.nth(2)).toHaveClass(/unavailable/); + await expect(form.getByTestId('command-argument-guidance')).toContainText('60턴 동안'); + await form.locator('#command-arg-destNationId').selectOption('2'); + await picker.screenshot({ path: testInfo.outputPath(`counter-strategy-${width}.png`) }); + await picker.getByRole('button', { name: '피장파장 입력', exact: true }).click(); + await expect + .poll(() => JSON.stringify(requests)) + .toContain('"action":"che_피장파장","args":{"destNationId":2,"commandType":"che_허보"}'); + + await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); + await picker.getByRole('button', { name: /^(?:국가:)?전략$/, exact: true }).click(); + await picker.getByRole('button', { name: /원조/ }).click(); + const table = picker.getByTestId('aid-level-table'); + await expect(table.locator('li')).toHaveText([ + '방랑군: 0', + '호족: 10,000', + '군벌: 20,000', + '주자사: 30,000', + '주목: 40,000', + '공: 50,000', + '왕: 60,000', + '황제: 70,000', + ]); + await expect(picker.getByTestId('command-resource-summary')).toContainText('국가 작위 주자사'); + const current = table.locator('li.current .aid-level-name'); + await expect(current).toHaveText('주자사'); + await expect(current).toHaveCSS('font-weight', '700'); + await expect(current).toHaveCSS('text-decoration-line', 'underline'); + const geometry = await picker.evaluate((element) => ({ + url: location.href, + viewport: [innerWidth, innerHeight], + overlayOverflow: element.scrollWidth - element.clientWidth, + table: element.querySelector('[data-testid=aid-level-table]')?.getBoundingClientRect().toJSON(), + nameWidths: [...element.querySelectorAll('.aid-level-name')].map( + (node) => node.getBoundingClientRect().width + ), + fontSize: getComputedStyle(element.querySelector('[data-testid=aid-level-table]')!).fontSize, + })); + expect(geometry.overlayOverflow).toBe(0); + expect(geometry.fontSize).toBe('14px'); + // Ref: width 4em inline-block. + expect(new Set(geometry.nameWidths)).toEqual(new Set([56])); + await writeFile(testInfo.outputPath(`aid-levels-${width}.json`), JSON.stringify(geometry, null, 2)); + await picker.screenshot({ path: testInfo.outputPath(`aid-levels-${width}.png`) }); + }); +} diff --git a/app/game-frontend/src/components/command/commandArgumentPresentation.ts b/app/game-frontend/src/components/command/commandArgumentPresentation.ts index 2e23e2ef..4ed3ee06 100644 --- a/app/game-frontend/src/components/command/commandArgumentPresentation.ts +++ b/app/game-frontend/src/components/command/commandArgumentPresentation.ts @@ -49,9 +49,12 @@ const PRESENTATIONS: Record = { '선택한 국가에 불가침을 제의합니다.', '불가침 기한 다음 달부터 다시 선전포고할 수 있습니다.', ]), + // Ref che_피장파장.vue: delayCnt 60턴, 아국 대기는 getTargetPostReqTurn()(최소 delayCnt × 1.2). che_피장파장: nationTarget([ - '선택한 국가가 지정한 전략을 일정 턴 동안 사용하지 못하게 합니다.', - '아국에도 지정 전략의 재사용 제한이 생깁니다.', + '선택한 국가가 지정한 전략을 60턴 동안 사용하지 못하게 합니다.', + '대신 아국도 지정한 전략을 72턴 이상 사용할 수 없습니다.', + '선포, 전쟁 중인 상대국에만 가능합니다.', + '재사용 대기 중인 전략은 붉은색으로 표시됩니다.', ]), che_물자원조: nationTarget(['타국에 금과 쌀을 원조합니다.', '국가 작위에 따라 보낼 수 있는 금액이 제한됩니다.']), diff --git a/app/game-frontend/src/components/command/commandNationDetails.ts b/app/game-frontend/src/components/command/commandNationDetails.ts new file mode 100644 index 00000000..d4d3b87e --- /dev/null +++ b/app/game-frontend/src/components/command/commandNationDetails.ts @@ -0,0 +1,58 @@ +import { nationLevelMap } from '../../utils/nationFormat.ts'; +import { legacyLuminanceTextColor } from '../../utils/legacyNationColor.ts'; +import type { CommandNationScoutMessage, CommandOption } from './types'; + +/** Ref GameConst::$coefAidAmount, Core che_물자원조 COEF_AID_AMOUNT. */ +export const AID_AMOUNT_PER_NATION_LEVEL = 10_000; + +export type AidLevelRow = { level: number; text: string; amount: number; current: boolean }; + +/** Ref che_물자원조 exportJSVars()의 levelInfo: 작위마다 작위 × coefAidAmount. */ +export const aidLevelTable = (currentLevel: number | undefined): AidLevelRow[] => + Object.keys(nationLevelMap) + .map(Number) + .sort((left, right) => left - right) + .map((level) => ({ + level, + text: nationLevelMap[level]!, + amount: level * AID_AMOUNT_PER_NATION_LEVEL, + current: level === currentLevel, + })); + +export const SCOUT_MESSAGE_COMMANDS: ReadonlySet = new Set(['che_임관', 'che_장수대상임관']); + +export type ScoutMessageRow = CommandNationScoutMessage & { textColor: string; selectable: boolean }; + +/** + * Ref che_임관·che_장수대상임관의 권유문 목록. + * 임관은 nation 표 순서이고, 장수대상임관은 getNationStaticInfo(0)의 재야를 앞에 둔다. + * 행을 눌러 국가를 고르는 것은 임관뿐이다. + */ +export const scoutMessageRows = ( + commandKey: string, + messages: readonly CommandNationScoutMessage[] | undefined +): ScoutMessageRow[] => { + if (!SCOUT_MESSAGE_COMMANDS.has(commandKey)) return []; + const nations = (messages ?? []).filter((entry) => entry.nationId > 0); + const rows = + commandKey === 'che_장수대상임관' + ? [{ nationId: 0, name: '재야', color: '#000000', message: '' }, ...nations] + : nations; + return rows.map((entry) => ({ + ...entry, + textColor: legacyLuminanceTextColor(entry.color), + selectable: commandKey === 'che_임관', + })); +}; + +/** Ref che_피장파장.vue: 재사용 대기 중인 전략은 `이름 (불가, N턴)`을 붉은색으로 보인다. */ +export const counterStrategyOption = ( + option: CommandOption, + cooldowns: Readonly> | undefined +): { label: string; remainTurn: number } => { + const remainTurn = cooldowns?.[String(option.value)] ?? 0; + return { + label: remainTurn > 0 ? `${option.label} (불가, ${remainTurn}턴)` : option.label, + remainTurn, + }; +}; diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts index 515f8d82..9215024f 100644 --- a/app/game-frontend/src/components/command/types.ts +++ b/app/game-frontend/src/components/command/types.ts @@ -20,6 +20,14 @@ export type CommandOption = { nationId?: number; }; +/** 임관 권유문. message는 Game API가 purifyNationHtml로 정제한 HTML이다. */ +export type CommandNationScoutMessage = { + nationId: number; + name: string; + color: string; + message: string; +}; + export type CommandAmountPreset = { values: number[]; defaultValue: number; @@ -122,6 +130,9 @@ export type CommandTable = { cities: CommandOption[]; nations: CommandOption[]; nationTargets?: Record; + nationScoutMessages?: CommandNationScoutMessage[]; + /** 아국 전략 명령별 남은 재사용 대기 턴. */ + strategyCooldowns?: Record; generals: CommandOption[]; generalTargets?: Record; crewTypes: CommandOption[]; diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index fd4f4463..51552ab2 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -1,5 +1,5 @@