국가 대상 명령 입력에 Ref 권유문·원조 한도·피장파장 전략 대기 복원
- 임관·장수대상임관: 정제된 임관 권유문 목록(크게/작게 보기, 940px 기준 2열)과 임관 대상 국가별 가능 여부(AllowJoinDestNation)를 명령표에 싣는다. - 물자원조: 작위별 원조 한도표를 보이고 현재 작위를 굵은 밑줄로 표시한다. - 피장파장: 전략 선택지를 원본 key 대신 전략 이름으로 보이고, 재사용 대기 중인 전략은 `(불가, N턴)` 붉은색으로 표시한다. 선포·전쟁 국가만 대상 가능으로 둔다. - 이호경식·급습: Ref 외교 조건으로 대상 국가 가능 여부를 계산한다. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
buildRecruitmentCommandInfo,
|
buildRecruitmentCommandInfo,
|
||||||
buildTurnCommandTable,
|
buildTurnCommandTable,
|
||||||
evaluateReservedTurnPermission,
|
evaluateReservedTurnPermission,
|
||||||
|
resolveCommandJoinEnv,
|
||||||
} from '../../turns/commandTable.js';
|
} from '../../turns/commandTable.js';
|
||||||
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
||||||
import {
|
import {
|
||||||
@@ -45,7 +46,10 @@ import {
|
|||||||
buildRefAmountPresets,
|
buildRefAmountPresets,
|
||||||
buildRefGeneralTargetOptions,
|
buildRefGeneralTargetOptions,
|
||||||
buildRefNationTargetOptions,
|
buildRefNationTargetOptions,
|
||||||
|
COUNTER_STRATEGY_COMMAND_KEYS,
|
||||||
} from '../../turns/commandTargets.js';
|
} from '../../turns/commandTargets.js';
|
||||||
|
import { resolveImpossibleStrategicCommands } from '../../services/mainNationProjection.js';
|
||||||
|
import { resolveNationBlockScout, resolveNationScoutMessage } from '../nation/shared.js';
|
||||||
|
|
||||||
const zPushAmount = z
|
const zPushAmount = z
|
||||||
.number()
|
.number()
|
||||||
@@ -357,10 +361,20 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
if (adjacentNationId && adjacentNationId !== general.nationId) adjacentNationIds.add(adjacentNationId);
|
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({
|
const nationTargetOptions = buildRefNationTargetOptions({
|
||||||
actorNationId: general.nationId,
|
actorNationId: general.nationId,
|
||||||
|
join: { actorNpcState: general.npcState, ...resolveCommandJoinEnv(worldState) },
|
||||||
|
strategyAvailable: COUNTER_STRATEGY_COMMAND_KEYS.some((key) => strategyCooldowns[key] === undefined),
|
||||||
nations: nations.map((entry) => {
|
nations: nations.map((entry) => {
|
||||||
const relation = diplomacyByNation.get(entry.id);
|
const relation = diplomacyByNation.get(entry.id);
|
||||||
|
const meta = asRecord(entry.meta);
|
||||||
|
const gennum = readGeneralMetaNumber(meta, 'gennum');
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
@@ -374,6 +388,9 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
diplomacyTerm: relation?.term ?? 0,
|
diplomacyTerm: relation?.term ?? 0,
|
||||||
adjacent: adjacentNationIds.has(entry.id),
|
adjacent: adjacentNationIds.has(entry.id),
|
||||||
diplomacyRestricted: (readGeneralMetaNumber(entry.meta, 'surlimit') ?? 0) !== 0,
|
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,
|
nations: nationTargetOptions.nations,
|
||||||
nationTargets: nationTargetOptions.nationTargets,
|
nationTargets: nationTargetOptions.nationTargets,
|
||||||
|
nationScoutMessages: nationTargetOptions.nationScoutMessages,
|
||||||
|
strategyCooldowns,
|
||||||
generals: generalTargetOptions.generals,
|
generals: generalTargetOptions.generals,
|
||||||
generalTargets: generalTargetOptions.generalTargets,
|
generalTargets: generalTargetOptions.generalTargets,
|
||||||
crewTypes: (environment.unitSet.crewTypes ?? [])
|
crewTypes: (environment.unitSet.crewTypes ?? [])
|
||||||
|
|||||||
@@ -99,10 +99,21 @@ export interface TurnCommandInputField {
|
|||||||
tupleLabels?: string[];
|
tupleLabels?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 임관 권유문. message는 서버에서 purifyNationHtml로 정제한 HTML이다. */
|
||||||
|
export interface TurnCommandNationScoutMessage {
|
||||||
|
nationId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TurnCommandInputOptions {
|
export interface TurnCommandInputOptions {
|
||||||
cities: TurnCommandOption[];
|
cities: TurnCommandOption[];
|
||||||
nations: TurnCommandOption[];
|
nations: TurnCommandOption[];
|
||||||
nationTargets?: Record<string, TurnCommandOption[]>;
|
nationTargets?: Record<string, TurnCommandOption[]>;
|
||||||
|
nationScoutMessages?: TurnCommandNationScoutMessage[];
|
||||||
|
/** 아국 전략 명령별 남은 재사용 대기 턴. 대기 중인 전략만 담는다. */
|
||||||
|
strategyCooldowns?: Record<string, number>;
|
||||||
generals: TurnCommandOption[];
|
generals: TurnCommandOption[];
|
||||||
generalTargets?: Record<string, TurnCommandOption[]>;
|
generalTargets?: Record<string, TurnCommandOption[]>;
|
||||||
crewTypes: TurnCommandOption[];
|
crewTypes: TurnCommandOption[];
|
||||||
@@ -242,7 +253,8 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
optionText: '행동',
|
optionText: '행동',
|
||||||
year: '기간(년)',
|
year: '기간(년)',
|
||||||
month: '기간(월)',
|
month: '기간(월)',
|
||||||
commandType: '대응 명령',
|
// 피장파장 전용. Ref che_피장파장.vue의 '전략 :'.
|
||||||
|
commandType: '전략',
|
||||||
amountList: '지원 물자',
|
amountList: '지원 물자',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -268,10 +280,14 @@ const STATIC_LABELS: Record<string, Record<string, string>> = {
|
|||||||
item: '도구',
|
item: '도구',
|
||||||
},
|
},
|
||||||
commandType: {
|
commandType: {
|
||||||
che_선전포고: '선전포고',
|
// 피장파장 대상 전략. Ref availableCommandTypeList의 name(getName()).
|
||||||
che_불가침제의: '불가침 제의',
|
che_필사즉생: '필사즉생',
|
||||||
che_불가침파기제의: '불가침 파기 제의',
|
che_백성동원: '백성동원',
|
||||||
che_종전제의: '종전 제의',
|
che_수몰: '수몰',
|
||||||
|
che_허보: '허보',
|
||||||
|
che_의병모집: '의병모집',
|
||||||
|
che_이호경식: '이호경식',
|
||||||
|
che_급습: '급습',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 => ({
|
const mapNationRow = (row: NationRow): Nation => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DIPLOMACY_STATE } from '@sammo-ts/logic';
|
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 {
|
export interface GeneralTargetSource {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -33,6 +33,20 @@ export interface NationTargetSource {
|
|||||||
diplomacyTerm: number;
|
diplomacyTerm: number;
|
||||||
adjacent: boolean;
|
adjacent: boolean;
|
||||||
diplomacyRestricted?: 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 {
|
export interface RefGeneralTargetOptions {
|
||||||
@@ -43,6 +57,7 @@ export interface RefGeneralTargetOptions {
|
|||||||
export interface RefNationTargetOptions {
|
export interface RefNationTargetOptions {
|
||||||
nations: TurnCommandOption[];
|
nations: TurnCommandOption[];
|
||||||
nationTargets: Record<string, TurnCommandOption[]>;
|
nationTargets: Record<string, TurnCommandOption[]>;
|
||||||
|
nationScoutMessages: TurnCommandNationScoutMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
|
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
|
||||||
@@ -197,14 +212,63 @@ const NATION_TARGET_COMMANDS = [
|
|||||||
'che_선전포고',
|
'che_선전포고',
|
||||||
'che_종전제의',
|
'che_종전제의',
|
||||||
'che_불가침파기제의',
|
'che_불가침파기제의',
|
||||||
|
'che_피장파장',
|
||||||
|
'che_이호경식',
|
||||||
|
'che_급습',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const nationAvailability = (
|
/** 피장파장으로 지정할 수 있는 전략(GameConst availableChiefCommand['전략']에서 자신 제외). */
|
||||||
action: (typeof NATION_TARGET_COMMANDS)[number],
|
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,
|
actorNationId: number,
|
||||||
target: NationTargetSource
|
target: NationTargetSource
|
||||||
): { available: boolean; reason: string } => {
|
): { available: boolean; reason: string } => {
|
||||||
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
|
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_물자원조') {
|
if (action === 'che_물자원조') {
|
||||||
return target.diplomacyRestricted
|
return target.diplomacyRestricted
|
||||||
? { available: false, reason: '상대국이 외교제한 중입니다.' }
|
? { available: false, reason: '상대국이 외교제한 중입니다.' }
|
||||||
@@ -233,7 +297,12 @@ const nationAvailability = (
|
|||||||
export const buildRefNationTargetOptions = (options: {
|
export const buildRefNationTargetOptions = (options: {
|
||||||
actorNationId: number;
|
actorNationId: number;
|
||||||
nations: readonly NationTargetSource[];
|
nations: readonly NationTargetSource[];
|
||||||
|
/** 없으면 임관 대상 가능 여부를 계산하지 않고 기본 국가 목록을 쓴다. */
|
||||||
|
join?: NationJoinContext;
|
||||||
|
/** 피장파장으로 지정할 수 있는 전략이 하나라도 있는지. 기본값 true. */
|
||||||
|
strategyAvailable?: boolean;
|
||||||
}): RefNationTargetOptions => {
|
}): RefNationTargetOptions => {
|
||||||
|
const strategyAvailable = options.strategyAvailable ?? true;
|
||||||
const baseOptions = options.nations.map<TurnCommandOption>((entry) => ({
|
const baseOptions = options.nations.map<TurnCommandOption>((entry) => ({
|
||||||
value: entry.id,
|
value: entry.id,
|
||||||
label: entry.name,
|
label: entry.name,
|
||||||
@@ -245,7 +314,7 @@ export const buildRefNationTargetOptions = (options: {
|
|||||||
for (const action of NATION_TARGET_COMMANDS) {
|
for (const action of NATION_TARGET_COMMANDS) {
|
||||||
nationTargets[action] = options.nations
|
nationTargets[action] = options.nations
|
||||||
.map((entry) => {
|
.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 relation = DIPLOMACY_LABELS[entry.diplomacyState] ?? `관계 ${entry.diplomacyState}`;
|
||||||
const term = entry.diplomacyTerm > 0 ? ` ${entry.diplomacyTerm}턴` : '';
|
const term = entry.diplomacyTerm > 0 ? ` ${entry.diplomacyTerm}턴` : '';
|
||||||
return {
|
return {
|
||||||
@@ -266,7 +335,31 @@ export const buildRefNationTargetOptions = (options: {
|
|||||||
)
|
)
|
||||||
.map(({ power: _power, ...entry }) => entry);
|
.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 = [
|
const RESOURCE_ACTION_GUIDE = [
|
||||||
|
|||||||
@@ -53,6 +53,24 @@ describe('turn command argument input', () => {
|
|||||||
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
|
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
|
||||||
{ key: 'amountList', kind: 'numberTuple' },
|
{ 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(
|
expect(
|
||||||
nationFields
|
nationFields
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
buildRefGeneralTargetOptions,
|
buildRefGeneralTargetOptions,
|
||||||
buildRefNationTargetOptions,
|
buildRefNationTargetOptions,
|
||||||
type GeneralTargetSource,
|
type GeneralTargetSource,
|
||||||
|
type NationTargetSource,
|
||||||
} from '../src/turns/commandTargets.js';
|
} from '../src/turns/commandTargets.js';
|
||||||
|
|
||||||
const general = (overrides: Partial<GeneralTargetSource>): GeneralTargetSource => ({
|
const general = (overrides: Partial<GeneralTargetSource>): GeneralTargetSource => ({
|
||||||
@@ -323,6 +324,88 @@ describe('Ref nation target guidance', () => {
|
|||||||
expect(result.nationTargets.che_불가침파기제의?.[0]?.description).toContain('불가침 12턴');
|
expect(result.nationTargets.che_불가침파기제의?.[0]?.description).toContain('불가침 12턴');
|
||||||
expect(result.nationTargets.che_불가침파기제의?.at(-1)).toMatchObject({ value: 4, availableNow: false });
|
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: '<p>어서 오시오</p>' },
|
||||||
|
{ ...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', () => {
|
describe('Ref amount presets', () => {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
import { readFile, writeFile } from 'node:fs/promises';
|
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 { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { gamePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
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}');
|
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('<p>아국 권유문</p>'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '위',
|
||||||
|
color: '#0000FF',
|
||||||
|
capitalName: '허창',
|
||||||
|
level: 2,
|
||||||
|
power: 800,
|
||||||
|
generalCount: 4,
|
||||||
|
cityCount: 2,
|
||||||
|
diplomacyState: 0,
|
||||||
|
diplomacyTerm: 6,
|
||||||
|
adjacent: true,
|
||||||
|
// 저장·조회 경로와 같은 정제 결과만 화면에 전달한다. script와 on* 속성은 남지 않는다.
|
||||||
|
scoutMessage: purifyNationHtml(
|
||||||
|
'<div style="width: 870px; height: 300px; background-color: #334455"><b>천하를 함께</b>' +
|
||||||
|
'<script>window.__scoutXss = 1</script><span onclick="window.__scoutXss = 2">함께</span></div>'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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<HTMLElement>('[data-testid=nation-scout-row]')[1]!;
|
||||||
|
const message = row.querySelector<HTMLElement>('.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`) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,9 +49,12 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
|
|||||||
'선택한 국가에 불가침을 제의합니다.',
|
'선택한 국가에 불가침을 제의합니다.',
|
||||||
'불가침 기한 다음 달부터 다시 선전포고할 수 있습니다.',
|
'불가침 기한 다음 달부터 다시 선전포고할 수 있습니다.',
|
||||||
]),
|
]),
|
||||||
|
// Ref che_피장파장.vue: delayCnt 60턴, 아국 대기는 getTargetPostReqTurn()(최소 delayCnt × 1.2).
|
||||||
che_피장파장: nationTarget([
|
che_피장파장: nationTarget([
|
||||||
'선택한 국가가 지정한 전략을 일정 턴 동안 사용하지 못하게 합니다.',
|
'선택한 국가가 지정한 전략을 60턴 동안 사용하지 못하게 합니다.',
|
||||||
'아국에도 지정 전략의 재사용 제한이 생깁니다.',
|
'대신 아국도 지정한 전략을 72턴 이상 사용할 수 없습니다.',
|
||||||
|
'선포, 전쟁 중인 상대국에만 가능합니다.',
|
||||||
|
'재사용 대기 중인 전략은 붉은색으로 표시됩니다.',
|
||||||
]),
|
]),
|
||||||
che_물자원조: nationTarget(['타국에 금과 쌀을 원조합니다.', '국가 작위에 따라 보낼 수 있는 금액이 제한됩니다.']),
|
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;
|
nationId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 임관 권유문. message는 Game API가 purifyNationHtml로 정제한 HTML이다. */
|
||||||
|
export type CommandNationScoutMessage = {
|
||||||
|
nationId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CommandAmountPreset = {
|
export type CommandAmountPreset = {
|
||||||
values: number[];
|
values: number[];
|
||||||
defaultValue: number;
|
defaultValue: number;
|
||||||
@@ -122,6 +130,9 @@ export type CommandTable = {
|
|||||||
cities: CommandOption[];
|
cities: CommandOption[];
|
||||||
nations: CommandOption[];
|
nations: CommandOption[];
|
||||||
nationTargets?: Record<string, CommandOption[]>;
|
nationTargets?: Record<string, CommandOption[]>;
|
||||||
|
nationScoutMessages?: CommandNationScoutMessage[];
|
||||||
|
/** 아국 전략 명령별 남은 재사용 대기 턴. */
|
||||||
|
strategyCooldowns?: Record<string, number>;
|
||||||
generals: CommandOption[];
|
generals: CommandOption[];
|
||||||
generalTargets?: Record<string, CommandOption[]>;
|
generalTargets?: Record<string, CommandOption[]>;
|
||||||
crewTypes: CommandOption[];
|
crewTypes: CommandOption[];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { commandTargetDescription } from '../../utils/commandTargetDescription';
|
||||||
import {
|
import {
|
||||||
isCommandTargetSearchField,
|
isCommandTargetSearchField,
|
||||||
@@ -17,12 +17,19 @@ import {
|
|||||||
NATION_GROUPED_GENERAL_COMMANDS,
|
NATION_GROUPED_GENERAL_COMMANDS,
|
||||||
type GeneralOptionGroup,
|
type GeneralOptionGroup,
|
||||||
} from '../command/commandGeneralGroups';
|
} from '../command/commandGeneralGroups';
|
||||||
|
import {
|
||||||
|
aidLevelTable,
|
||||||
|
counterStrategyOption,
|
||||||
|
scoutMessageRows,
|
||||||
|
type ScoutMessageRow,
|
||||||
|
} from '../command/commandNationDetails';
|
||||||
import {
|
import {
|
||||||
commandArgumentFieldContract,
|
commandArgumentFieldContract,
|
||||||
shouldPreserveCommandArgumentValue,
|
shouldPreserveCommandArgumentValue,
|
||||||
type CommandArgumentFieldContract,
|
type CommandArgumentFieldContract,
|
||||||
} from '../command/commandArgumentDraft';
|
} from '../command/commandArgumentDraft';
|
||||||
import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
||||||
|
import { formatNationLevelText } from '../../utils/nationFormat';
|
||||||
import { getNpcColor } from '../../utils/npcColor';
|
import { getNpcColor } from '../../utils/npcColor';
|
||||||
import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
|
import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -249,9 +256,18 @@ const colorOptionStyle = (field: CommandInputField, option?: CommandOption): CSS
|
|||||||
if (field.optionSource === 'generals' && option?.npcState !== undefined) {
|
if (field.optionSource === 'generals' && option?.npcState !== undefined) {
|
||||||
return { color: getNpcColor(option.npcState) };
|
return { color: getNpcColor(option.npcState) };
|
||||||
}
|
}
|
||||||
|
if (isCounterStrategyField(field) && option && strategyOption(option).remainTurn > 0) {
|
||||||
|
return { color: 'red' };
|
||||||
|
}
|
||||||
return undefined;
|
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(() =>
|
const cityTargetField = computed(() =>
|
||||||
props.fields.find(
|
props.fields.find(
|
||||||
(field) =>
|
(field) =>
|
||||||
@@ -265,6 +281,34 @@ const nationTargetField = computed(() =>
|
|||||||
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
|
(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 mapTarget = computed(() => resolveCommandArgumentMapTarget(props.commandKey, props.fields));
|
||||||
const showMap = computed(() => Boolean(props.mapData && props.mapLayout && mapTarget.value));
|
const showMap = computed(() => Boolean(props.mapData && props.mapLayout && mapTarget.value));
|
||||||
const mapSelectedCityId = computed<number | null>(() => {
|
const mapSelectedCityId = computed<number | null>(() => {
|
||||||
@@ -404,7 +448,7 @@ const resourceSummary = computed(() => {
|
|||||||
if (usesNationResources.has(props.commandKey)) {
|
if (usesNationResources.has(props.commandKey)) {
|
||||||
if (context.nationGold !== undefined) result.push(`국고 ${context.nationGold.toLocaleString()}`);
|
if (context.nationGold !== undefined) result.push(`국고 ${context.nationGold.toLocaleString()}`);
|
||||||
if (context.nationRice !== undefined) result.push(`국가 군량 ${context.nationRice.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;
|
return result;
|
||||||
});
|
});
|
||||||
@@ -497,7 +541,7 @@ watch(
|
|||||||
<div
|
<div
|
||||||
v-if="props.fields.length || showMap || presentation.lines.length"
|
v-if="props.fields.length || showMap || presentation.lines.length"
|
||||||
class="command-argument-form"
|
class="command-argument-form"
|
||||||
:class="{ 'has-map': showMap }"
|
:class="{ 'has-map': showMap, 'has-scout-list': scoutRows.length > 0 }"
|
||||||
data-testid="command-argument-form"
|
data-testid="command-argument-form"
|
||||||
>
|
>
|
||||||
<div v-if="showMap" class="command-map" data-testid="command-argument-map">
|
<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">
|
<div v-if="resourceSummary.length" class="resource-summary" data-testid="command-resource-summary">
|
||||||
<span v-for="entry in resourceSummary" :key="entry">{{ entry }}</span>
|
<span v-for="entry in resourceSummary" :key="entry">{{ entry }}</span>
|
||||||
</div>
|
</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">
|
<div v-for="field in visibleFields" :key="field.key" class="argument-row">
|
||||||
<label :for="`command-arg-${field.key}`">{{ field.label }}</label>
|
<label :for="`command-arg-${field.key}`">{{ field.label }}</label>
|
||||||
<input
|
<input
|
||||||
@@ -614,7 +664,7 @@ watch(
|
|||||||
:value="String(option.value)"
|
:value="String(option.value)"
|
||||||
:style="colorOptionStyle(field, option)"
|
:style="colorOptionStyle(field, option)"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
{{ optionText(field, option) }}
|
||||||
</option>
|
</option>
|
||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
@@ -788,6 +838,59 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
|
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -853,6 +956,134 @@ small {
|
|||||||
padding: 8px;
|
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 {
|
.command-argument-form {
|
||||||
border: 1px solid rgba(201, 164, 90, 0.35);
|
border: 1px solid rgba(201, 164, 90, 0.35);
|
||||||
font-size: var(--sammo-font-size-small);
|
font-size: var(--sammo-font-size-small);
|
||||||
@@ -881,6 +1112,11 @@ small {
|
|||||||
top: var(--argument-overlay-header-height, 0px);
|
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 {
|
.command-argument-form.has-map > .argument-fields {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-left: 1px solid rgba(201, 164, 90, 0.35);
|
border-left: 1px solid rgba(201, 164, 90, 0.35);
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
aidLevelTable,
|
||||||
|
counterStrategyOption,
|
||||||
|
scoutMessageRows,
|
||||||
|
} from '../src/components/command/commandNationDetails.ts';
|
||||||
|
|
||||||
|
void test('aid level table follows Ref levelInfo: every level × coefAidAmount with the current level marked', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
aidLevelTable(3).map((row) => [row.text, row.amount, row.current]),
|
||||||
|
[
|
||||||
|
['방랑군', 0, false],
|
||||||
|
['호족', 10_000, false],
|
||||||
|
['군벌', 20_000, false],
|
||||||
|
['주자사', 30_000, true],
|
||||||
|
['주목', 40_000, false],
|
||||||
|
['공', 50_000, false],
|
||||||
|
['왕', 60_000, false],
|
||||||
|
['황제', 70_000, false],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
aidLevelTable(undefined).some((row) => row.current),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('scout rows keep nation order, prepend 재야 only for 장수대상임관 and select only for 임관', () => {
|
||||||
|
const messages = [
|
||||||
|
{ nationId: 2, name: '위', color: '#0000FF', message: '<p>위</p>' },
|
||||||
|
{ nationId: 1, name: '촉', color: '#FFFF00', message: '' },
|
||||||
|
];
|
||||||
|
const join = scoutMessageRows('che_임관', messages);
|
||||||
|
assert.deepEqual(
|
||||||
|
join.map((row) => [row.nationId, row.name, row.textColor, row.selectable]),
|
||||||
|
[
|
||||||
|
[2, '위', '#FFFFFF', true],
|
||||||
|
[1, '촉', '#000000', true],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const follow = scoutMessageRows('che_장수대상임관', messages);
|
||||||
|
assert.deepEqual(
|
||||||
|
follow.map((row) => [row.nationId, row.name, row.color, row.message, row.selectable]),
|
||||||
|
[
|
||||||
|
[0, '재야', '#000000', '', false],
|
||||||
|
[2, '위', '#0000FF', '<p>위</p>', false],
|
||||||
|
[1, '촉', '#FFFF00', '', false],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.deepEqual(scoutMessageRows('che_등용', messages), []);
|
||||||
|
assert.deepEqual(scoutMessageRows('che_임관', undefined), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('counter strategy options show Ref remaining turns', () => {
|
||||||
|
const option = { value: 'che_수몰', label: '수몰' };
|
||||||
|
assert.deepEqual(counterStrategyOption(option, { che_수몰: 12 }), { label: '수몰 (불가, 12턴)', remainTurn: 12 });
|
||||||
|
assert.deepEqual(counterStrategyOption(option, { che_허보: 3 }), { label: '수몰', remainTurn: 0 });
|
||||||
|
assert.deepEqual(counterStrategyOption(option, undefined), { label: '수몰', remainTurn: 0 });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user