국가 대상 명령 입력에 Ref 권유문·원조 한도·피장파장 전략 대기 복원

- 임관·장수대상임관: 정제된 임관 권유문 목록(크게/작게 보기, 940px 기준 2열)과
  임관 대상 국가별 가능 여부(AllowJoinDestNation)를 명령표에 싣는다.
- 물자원조: 작위별 원조 한도표를 보이고 현재 작위를 굵은 밑줄로 표시한다.
- 피장파장: 전략 선택지를 원본 key 대신 전략 이름으로 보이고, 재사용 대기 중인
  전략은 `(불가, N턴)` 붉은색으로 표시한다. 선포·전쟁 국가만 대상 가능으로 둔다.
- 이호경식·급습: Ref 외교 조건으로 대상 국가 가능 여부를 계산한다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 15:24:31 +00:00
co-authored by Claude Opus 5.5
parent 4a50c354fe
commit f74c3322b7
12 changed files with 893 additions and 17 deletions
@@ -49,9 +49,12 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
'선택한 국가에 불가침을 제의합니다.',
'불가침 기한 다음 달부터 다시 선전포고할 수 있습니다.',
]),
// Ref che_피장파장.vue: delayCnt 60턴, 아국 대기는 getTargetPostReqTurn()(최소 delayCnt × 1.2).
che_피장파장: nationTarget([
'선택한 국가가 지정한 전략을 일정 턴 동안 사용하지 못하게 합니다.',
'아국에도 지정 전략의 재사용 제한이 생깁니다.',
'선택한 국가가 지정한 전략을 60턴 동안 사용하지 못하게 합니다.',
'대신 아국도 지정한 전략을 72턴 이상 사용할 수 없습니다.',
'선포, 전쟁 중인 상대국에만 가능합니다.',
'재사용 대기 중인 전략은 붉은색으로 표시됩니다.',
]),
che_물자원조: nationTarget(['타국에 금과 쌀을 원조합니다.', '국가 작위에 따라 보낼 수 있는 금액이 제한됩니다.']),
@@ -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<string> = 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<Record<string, number>> | undefined
): { label: string; remainTurn: number } => {
const remainTurn = cooldowns?.[String(option.value)] ?? 0;
return {
label: remainTurn > 0 ? `${option.label} (불가, ${remainTurn}턴)` : option.label,
remainTurn,
};
};
@@ -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<string, CommandOption[]>;
nationScoutMessages?: CommandNationScoutMessage[];
/** 아국 전략 명령별 남은 재사용 대기 턴. */
strategyCooldowns?: Record<string, number>;
generals: CommandOption[];
generalTargets?: Record<string, CommandOption[]>;
crewTypes: CommandOption[];
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, watch, type CSSProperties } from 'vue';
import { computed, onBeforeUnmount, reactive, ref, watch, type CSSProperties } from 'vue';
import { commandTargetDescription } from '../../utils/commandTargetDescription';
import {
isCommandTargetSearchField,
@@ -17,12 +17,19 @@ import {
NATION_GROUPED_GENERAL_COMMANDS,
type GeneralOptionGroup,
} from '../command/commandGeneralGroups';
import {
aidLevelTable,
counterStrategyOption,
scoutMessageRows,
type ScoutMessageRow,
} from '../command/commandNationDetails';
import {
commandArgumentFieldContract,
shouldPreserveCommandArgumentValue,
type CommandArgumentFieldContract,
} from '../command/commandArgumentDraft';
import { legacyNationTextColor } from '../../utils/legacyNationColor';
import { formatNationLevelText } from '../../utils/nationFormat';
import { getNpcColor } from '../../utils/npcColor';
import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
import type {
@@ -249,9 +256,18 @@ const colorOptionStyle = (field: CommandInputField, option?: CommandOption): CSS
if (field.optionSource === 'generals' && option?.npcState !== undefined) {
return { color: getNpcColor(option.npcState) };
}
if (isCounterStrategyField(field) && option && strategyOption(option).remainTurn > 0) {
return { color: 'red' };
}
return undefined;
};
const isCounterStrategyField = (field: CommandInputField): boolean =>
props.commandKey === 'che_피장파장' && field.key === 'commandType';
const strategyOption = (option: CommandOption) => counterStrategyOption(option, props.options.strategyCooldowns);
const optionText = (field: CommandInputField, option: CommandOption): string =>
isCounterStrategyField(field) ? strategyOption(option).label : option.label;
const cityTargetField = computed(() =>
props.fields.find(
(field) =>
@@ -265,6 +281,34 @@ const nationTargetField = computed(() =>
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
)
);
// Ref che_물자원조.vue의 작위별 원조 한도표.
const aidLevels = computed(() =>
props.commandKey === 'che_물자원조' ? aidLevelTable(props.options.context?.nationLevel) : []
);
// Ref che_임관·che_장수대상임관.vue의 권유문 목록. 기본은 Ref toggleZoom=true(작게 보기 버튼 표시).
const scoutRows = computed(() => scoutMessageRows(props.commandKey, props.options.nationScoutMessages));
const scoutZoom = ref(true);
const scoutListElement = ref<HTMLElement | null>(null);
const scoutListWidth = ref(0);
let scoutResizeObserver: ResizeObserver | null = null;
watch(scoutListElement, (element) => {
scoutResizeObserver?.disconnect();
scoutResizeObserver = null;
if (!element || typeof ResizeObserver === 'undefined') return;
scoutResizeObserver = new ResizeObserver(([entry]) => {
scoutListWidth.value = entry?.contentRect.width ?? 0;
});
scoutResizeObserver.observe(element);
});
// Ref는 500px 화면에서 870px 권유문을 500/870로 줄인다. Core는 실제 목록 폭 기준으로 같은 비율을 쓴다.
const scoutFitScale = computed(() => (scoutListWidth.value > 0 ? Math.min(1, scoutListWidth.value / 870) : 500 / 870));
const selectScoutNation = (row: ScoutMessageRow) => {
if (!row.selectable || !nationTargetField.value) return;
setSelectValue(nationTargetField.value, String(row.nationId));
};
onBeforeUnmount(() => scoutResizeObserver?.disconnect());
const mapTarget = computed(() => resolveCommandArgumentMapTarget(props.commandKey, props.fields));
const showMap = computed(() => Boolean(props.mapData && props.mapLayout && mapTarget.value));
const mapSelectedCityId = computed<number | null>(() => {
@@ -404,7 +448,7 @@ const resourceSummary = computed(() => {
if (usesNationResources.has(props.commandKey)) {
if (context.nationGold !== undefined) result.push(`국고 ${context.nationGold.toLocaleString()}`);
if (context.nationRice !== undefined) result.push(`국가 군량 ${context.nationRice.toLocaleString()}`);
if (context.nationLevel !== undefined) result.push(`국가 작위 ${context.nationLevel}`);
if (context.nationLevel !== undefined) result.push(`국가 작위 ${formatNationLevelText(context.nationLevel)}`);
}
return result;
});
@@ -497,7 +541,7 @@ watch(
<div
v-if="props.fields.length || showMap || presentation.lines.length"
class="command-argument-form"
:class="{ 'has-map': showMap }"
:class="{ 'has-map': showMap, 'has-scout-list': scoutRows.length > 0 }"
data-testid="command-argument-form"
>
<div v-if="showMap" class="command-map" data-testid="command-argument-map">
@@ -534,6 +578,12 @@ watch(
<div v-if="resourceSummary.length" class="resource-summary" data-testid="command-resource-summary">
<span v-for="entry in resourceSummary" :key="entry">{{ entry }}</span>
</div>
<ul v-if="aidLevels.length" class="aid-level-table" data-testid="aid-level-table">
<li v-for="row in aidLevels" :key="row.level" :class="{ current: row.current }">
<span class="aid-level-name">{{ row.text }}</span
>: {{ row.amount.toLocaleString() }}
</li>
</ul>
<div v-for="field in visibleFields" :key="field.key" class="argument-row">
<label :for="`command-arg-${field.key}`">{{ field.label }}</label>
<input
@@ -614,7 +664,7 @@ watch(
:value="String(option.value)"
:style="colorOptionStyle(field, option)"
>
{{ option.label }}
{{ optionText(field, option) }}
</option>
</template>
</select>
@@ -788,6 +838,59 @@ watch(
</div>
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
</div>
<section
v-if="scoutRows.length"
ref="scoutListElement"
class="nation-scout-list"
data-testid="nation-scout-list"
aria-label="임관 권유문"
:style="{ '--scout-fit-scale': String(scoutFitScale) }"
>
<div class="nation-scout-header">
<div>국가명</div>
<div>임관권유문</div>
<button
type="button"
class="scout-zoom-toggle legacy-button"
:class="scoutZoom ? 'legacy-button--primary' : 'legacy-button--secondary'"
:aria-pressed="scoutZoom"
@click="scoutZoom = !scoutZoom"
>
{{ scoutZoom ? '작게 보기' : '크게 보기' }}
</button>
</div>
<div
v-for="row in scoutRows"
:key="row.nationId"
class="nation-scout-row"
:class="[
scoutZoom ? 'on-zoom' : 'on-fit',
{
selectable: row.selectable,
selected: row.selectable && row.nationId === values[nationTargetField?.key ?? ''],
},
]"
data-testid="nation-scout-row"
@click="selectScoutNation(row)"
>
<div class="nation-scout-name" :style="{ backgroundColor: row.color, color: row.textColor }">
<button
v-if="row.selectable"
type="button"
:aria-pressed="row.nationId === values[nationTargetField?.key ?? '']"
@click.stop="selectScoutNation(row)"
>
{{ row.name }}
</button>
<span v-else>{{ row.name }}</span>
</div>
<div class="nation-scout-plate">
<!-- message는 Game API가 purifyNationHtml(sanitize-html allowlist)로 저장·조회 시 모두 정제한다. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="nation-scout-msg" v-html="row.message" />
</div>
</div>
</section>
</div>
</template>
@@ -853,6 +956,134 @@ small {
padding: 8px;
}
.aid-level-table {
margin: 0;
padding: 4px 8px 6px 28px;
color: #e8ddc4;
font-size: var(--sammo-font-size-normal);
line-height: 1.45;
}
.aid-level-name {
display: inline-block;
width: 4em;
}
.aid-level-table li.current .aid-level-name {
font-weight: bold;
text-decoration: underline;
}
/*
* Ref che_임관.vue nation-list. 940px 이상은 130px 국가명 + 870px 권유문, 그 아래는 한 열이다.
* 지도·입력 2열 아래에 전체 폭으로 둔다.
*/
.nation-scout-list {
grid-column: 1 / -1;
min-width: 0;
border-top: 1px solid rgba(201, 164, 90, 0.35);
color: #e8ddc4;
font-size: var(--sammo-font-size-normal);
}
.nation-scout-header {
display: grid;
grid-template-columns: 3fr 1fr;
grid-template-rows: auto auto;
align-items: center;
background: rgba(255, 255, 255, 0.08);
text-align: center;
}
.nation-scout-header > .scout-zoom-toggle {
grid-column: 2 / 3;
grid-row: 1 / 3;
margin: 2px;
}
.nation-scout-row {
display: grid;
grid-template-columns: minmax(0, 1fr);
border-bottom: 1px solid rgba(201, 164, 90, 0.35);
}
.nation-scout-row.selectable {
cursor: pointer;
}
.nation-scout-row.selected {
outline: 2px solid #f1d89a;
outline-offset: -2px;
}
.nation-scout-name {
display: grid;
place-items: center;
min-height: 2em;
font-size: 1.3em;
text-align: center;
}
.nation-scout-name button {
border: 0;
padding: 0;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.nation-scout-plate {
min-width: 0;
align-self: center;
}
.nation-scout-row.on-fit .nation-scout-plate {
max-height: calc(200px * var(--scout-fit-scale));
overflow: hidden;
}
.nation-scout-row.on-fit .nation-scout-msg {
width: 870px;
transform-origin: 0 0;
transform: scale(var(--scout-fit-scale));
}
.nation-scout-row.on-zoom .nation-scout-plate {
max-height: 200px;
overflow-x: auto;
overflow-y: hidden;
}
.nation-scout-row.on-zoom .nation-scout-msg {
max-width: 870px;
}
@media (min-width: 940px) {
.nation-scout-header,
.nation-scout-row {
grid-template-columns: 130px minmax(0, 870px);
grid-template-rows: auto;
}
.nation-scout-header > .scout-zoom-toggle {
display: none;
}
.nation-scout-row.on-fit .nation-scout-plate,
.nation-scout-row.on-zoom .nation-scout-plate {
max-height: none;
overflow-x: auto;
overflow-y: visible;
}
.nation-scout-row.on-fit .nation-scout-msg {
width: auto;
max-width: 870px;
transform: none;
}
}
.command-argument-form {
border: 1px solid rgba(201, 164, 90, 0.35);
font-size: var(--sammo-font-size-small);
@@ -881,6 +1112,11 @@ small {
top: var(--argument-overlay-header-height, 0px);
}
/* 전체 폭 권유문 목록이 지도 아래에 오므로 지도가 목록을 덮지 않게 고정하지 않는다. */
.command-argument-form.has-map.has-scout-list > .command-map {
position: static;
}
.command-argument-form.has-map > .argument-fields {
min-width: 0;
border-left: 1px solid rgba(201, 164, 90, 0.35);