feat: 사령부 명령 필요 턴을 표시한다

Ref처럼 여러 턴이 필요한 국가 명령의 소요 턴을 명령명 아래에 표시한다. 실행 getPreReqTurn을 기준으로 삼고 천도는 거리 기반 공식을 노출한다.
This commit is contained in:
2026-08-25 04:14:47 +00:00
parent 053f1a3e68
commit 673b8d8a54
7 changed files with 114 additions and 19 deletions
+26 -2
View File
@@ -39,6 +39,7 @@ type AvailabilityStatus = 'available' | 'blocked' | 'needsInput' | 'unknown';
export interface TurnCommandAvailability { export interface TurnCommandAvailability {
key: string; key: string;
name: string; name: string;
turnDurationText?: string;
reqArg: boolean; reqArg: boolean;
possible: boolean; possible: boolean;
status: AvailabilityStatus; status: AvailabilityStatus;
@@ -699,16 +700,34 @@ const buildEntries = (
return entries; return entries;
}; };
const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: StateView): TurnCommandGroup[] => { const getTurnDurationText = (definition: GeneralActionDefinition): string | undefined => {
const hint = definition.getTurnDurationHint?.();
if (hint) return hint;
const getPreReqTurn = definition.getPreReqTurn;
// 인자를 선언한 구현은 천도처럼 대상에 따라 달라지므로 임의 context로 실행하지 않는다.
if (!getPreReqTurn || getPreReqTurn.length > 0) return undefined;
const preReqTurn = Math.max(0, Math.floor(getPreReqTurn.call(definition, undefined as never, {})));
return preReqTurn > 0 ? `${preReqTurn + 1}` : undefined;
};
const buildGroups = (
entries: CommandEntry[],
ctx: ConstraintContext,
view: StateView,
includeTurnDuration = false
): TurnCommandGroup[] => {
const groups = new Map<string, TurnCommandAvailability[]>(); const groups = new Map<string, TurnCommandAvailability[]>();
for (const entry of entries) { for (const entry of entries) {
const availability = entry.evaluate const availability = entry.evaluate
? entry.evaluate(ctx, view) ? entry.evaluate(ctx, view)
: evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs); : evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs);
const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined;
const value: TurnCommandAvailability = { const value: TurnCommandAvailability = {
key: entry.definition.key, key: entry.definition.key,
name: entry.definition.name, name: entry.definition.name,
...(turnDurationText ? { turnDurationText } : {}),
reqArg: entry.reqArg, reqArg: entry.reqArg,
inputFields: entry.inputFields, inputFields: entry.inputFields,
...availability, ...availability,
@@ -806,7 +825,12 @@ export const buildTurnCommandTable = async (options: {
ctx, ctx,
view view
), ),
nation: buildGroups(projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS), ctx, view), nation: buildGroups(
projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS),
ctx,
view,
true
),
inputOptions: options.inputOptions ?? { inputOptions: options.inputOptions ?? {
cities: [], cities: [],
nations: [], nations: [],
+29
View File
@@ -190,6 +190,27 @@ describe('buildTurnCommandTable', () => {
: ['필사즉생', '백성동원', '수몰', '허보', '의병모집', '이호경식', '급습', '피장파장'], : ['필사즉생', '백성동원', '수몰', '허보', '의병모집', '이호경식', '급습', '피장파장'],
: ['국기변경', '국호변경'], : ['국기변경', '국호변경'],
}); });
expect(
Object.fromEntries(
table.nation
.flatMap(({ values }) => values)
.filter(({ turnDurationText }) => turnDurationText)
.map(({ key, turnDurationText }) => [key, turnDurationText])
)
).toEqual({
che_초토화: '3턴',
che_천도: '1+거리×2턴',
che_증축: '6턴',
che_감축: '6턴',
che_필사즉생: '3턴',
che_수몰: '3턴',
che_허보: '2턴',
che_의병모집: '3턴',
che_피장파장: '2턴',
});
expect(table.general.flatMap(({ values }) => values)).not.toContainEqual(
expect.objectContaining({ turnDurationText: expect.any(String) })
);
}); });
it('projects scenario-specific command categories instead of the default profile', async () => { it('projects scenario-specific command categories instead of the default profile', async () => {
@@ -225,6 +246,14 @@ describe('buildTurnCommandTable', () => {
'event_대검병연구', 'event_대검병연구',
'event_화륜차연구', 'event_화륜차연구',
]); ]);
expect(
Object.fromEntries(
table.nation.flatMap(({ values }) => values.map(({ key, turnDurationText }) => [key, turnDurationText]))
)
).toMatchObject({
event_대검병연구: '12턴',
event_화륜차연구: '24턴',
});
}); });
it('projects the real 904/905/910/912 world command profiles into the API table', async () => { it('projects the real 904/905/910/912 world command profiles into the API table', async () => {
+38 -11
View File
@@ -320,9 +320,10 @@ const buildGeneralCommand = (key: string, name: string) => ({
{ key: 'destGeneralId', label: '대상 장수', kind: 'select', required: true, optionSource: 'generals' }, { key: 'destGeneralId', label: '대상 장수', kind: 'select', required: true, optionSource: 'generals' },
], ],
}); });
const buildSimpleCommand = (key: string, name: string) => ({ const buildSimpleCommand = (key: string, name: string, turnDurationText?: string) => ({
key, key,
name, name,
...(turnDurationText ? { turnDurationText } : {}),
reqArg: false, reqArg: false,
possible: true, possible: true,
status: 'available', status: 'available',
@@ -635,23 +636,23 @@ const refChiefCommandTable = {
{ {
category: '특수', category: '특수',
values: [ values: [
buildSimpleCommand('che_초토화', '초토화'), buildSimpleCommand('che_초토화', '초토화', '3턴'),
buildSimpleCommand('che_천도', '천도'), buildSimpleCommand('che_천도', '천도', '1+거리×2턴'),
buildSimpleCommand('che_증축', '증축'), buildSimpleCommand('che_증축', '증축', '6턴'),
buildSimpleCommand('che_감축', '감축'), buildSimpleCommand('che_감축', '감축', '6턴'),
], ],
}, },
{ {
category: '전략', category: '전략',
values: [ values: [
buildSimpleCommand('che_필사즉생', '필사즉생'), buildSimpleCommand('che_필사즉생', '필사즉생', '3턴'),
buildSimpleCommand('che_백성동원', '백성동원'), buildSimpleCommand('che_백성동원', '백성동원'),
buildSimpleCommand('che_수몰', '수몰'), buildSimpleCommand('che_수몰', '수몰', '3턴'),
buildSimpleCommand('che_허보', '허보'), buildSimpleCommand('che_허보', '허보', '2턴'),
buildSimpleCommand('che_의병모집', '의병모집'), buildSimpleCommand('che_의병모집', '의병모집', '3턴'),
buildSimpleCommand('che_이호경식', '이호경식'), buildSimpleCommand('che_이호경식', '이호경식'),
buildSimpleCommand('che_급습', '급습'), buildSimpleCommand('che_급습', '급습'),
buildSimpleCommand('che_피장파장', '피장파장'), buildSimpleCommand('che_피장파장', '피장파장', '2턴'),
], ],
}, },
{ {
@@ -1220,8 +1221,24 @@ test('shows every Ref chief command in the exact category and command order', as
await expect(picker.locator('.category-btn')).toHaveText(Object.keys(expected)); await expect(picker.locator('.category-btn')).toHaveText(Object.keys(expected));
for (const [category, commands] of Object.entries(expected)) { for (const [category, commands] of Object.entries(expected)) {
await picker.locator('.category-btn').filter({ hasText: category }).click(); await picker.locator('.category-btn').filter({ hasText: category }).click();
await expect(picker.locator('.command-grid .command-item')).toHaveText([...commands]); await expect(picker.locator('.command-grid .command-name')).toHaveText([...commands]);
} }
await picker.getByRole('button', { name: '특수', exact: true }).click();
await expect(picker.locator('.command-grid .command-item')).toHaveText([
'초토화 /3턴',
'천도 /1+거리×2턴',
'증축 /6턴',
'감축 /6턴',
]);
const durationGeometry = await picker.locator('.command-grid .command-item').evaluateAll((buttons) =>
buttons.map((button) => ({
text: button.textContent?.replace(/\s/g, ''),
height: button.getBoundingClientRect().height,
overflow: button.scrollWidth - button.clientWidth,
}))
);
expect(durationGeometry.every(({ height, overflow }) => height >= 35 && overflow <= 0)).toBe(true);
await picker.getByRole('button', { name: '기타', exact: true }).click();
const rename = picker.getByRole('button', { name: '국호변경', exact: true }); const rename = picker.getByRole('button', { name: '국호변경', exact: true });
await rename.hover(); await rename.hover();
await rename.focus(); await rename.focus();
@@ -1242,6 +1259,16 @@ test('shows every Ref chief command in the exact category and command order', as
expect(mobileGeometry.width).toBeLessThanOrEqual(500); expect(mobileGeometry.width).toBeLessThanOrEqual(500);
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0); expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3); expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3);
await mobilePicker.getByRole('button', { name: '특수', exact: true }).click();
const mobileExpand = mobilePicker.getByRole('button', { name: '증축 /6턴', exact: true });
await expect(mobileExpand).toBeVisible();
await mobileExpand.hover();
await mobileExpand.focus();
await expect(mobileExpand).toBeFocused();
await mobileExpand.dispatchEvent('pointerdown');
await expect(mobileExpand.locator('.command-duration')).toHaveText('/6턴');
await mobileExpand.dispatchEvent('pointerup');
expect(await mobilePicker.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') }); await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') });
}); });
@@ -63,6 +63,7 @@ export type CommandInputField = {
export type CommandAvailability = { export type CommandAvailability = {
key: string; key: string;
name: string; name: string;
turnDurationText?: string;
reqArg: boolean; reqArg: boolean;
status: 'available' | 'blocked' | 'needsInput' | 'unknown'; status: 'available' | 'blocked' | 'needsInput' | 'unknown';
possible: boolean; possible: boolean;
@@ -5,6 +5,7 @@ import SkeletonLines from '../ui/SkeletonLines.vue';
type CommandAvailability = { type CommandAvailability = {
key: string; key: string;
name: string; name: string;
turnDurationText?: string;
reqArg: boolean; reqArg: boolean;
status: 'available' | 'blocked' | 'needsInput' | 'unknown'; status: 'available' | 'blocked' | 'needsInput' | 'unknown';
possible: boolean; possible: boolean;
@@ -161,6 +162,9 @@ const commandTitle = (command: CommandAvailability) =>
@click="emit('select', command.key)" @click="emit('select', command.key)"
> >
<span class="command-name">{{ command.name }}</span> <span class="command-name">{{ command.name }}</span>
<small v-if="command.turnDurationText" class="command-duration">
/{{ command.turnDurationText }}
</small>
</button> </button>
</div> </div>
</div> </div>
@@ -210,6 +214,7 @@ const commandTitle = (command: CommandAvailability) =>
min-height: 0; min-height: 0;
padding-inline: 5px; padding-inline: 5px;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
@@ -236,6 +241,14 @@ const commandTitle = (command: CommandAvailability) =>
font-weight: 600; font-weight: 600;
} }
.command-duration {
display: block;
font-size: 0.875em;
font-weight: 400;
line-height: 1.1;
white-space: nowrap;
}
.empty { .empty {
color: rgba(232, 221, 196, 0.6); color: rgba(232, 221, 196, 0.6);
} }
+3 -6
View File
@@ -16,14 +16,11 @@ export interface GeneralActionDefinition<
// 커맨드 입력 단계에서 최소 조건만 평가할 때 사용한다. // 커맨드 입력 단계에서 최소 조건만 평가할 때 사용한다.
buildMinConstraints?(ctx: ConstraintContext, args: Args): Constraint[]; buildMinConstraints?(ctx: ConstraintContext, args: Args): Constraint[];
buildConstraints(ctx: ConstraintContext, args: Args): Constraint[]; buildConstraints(ctx: ConstraintContext, args: Args): Constraint[];
formatConstraintFailure?( formatConstraintFailure?(reason: string, ctx: ConstraintContext, args: Args, view: StateView): string | null;
reason: string,
ctx: ConstraintContext,
args: Args,
view: StateView
): string | null;
// NationCommand::addTermStack()/setNextAvailable() 호환 실행 메타데이터. // NationCommand::addTermStack()/setNextAvailable() 호환 실행 메타데이터.
getPreReqTurn?(context: Context, args: Args): number; getPreReqTurn?(context: Context, args: Args): number;
// 입력 시점에 대상이 정해져야 소요 턴을 계산할 수 있는 명령의 Ref 표시 문구.
getTurnDurationHint?(): string;
getPostReqTurn?(context: Context, args: Args): number; getPostReqTurn?(context: Context, args: Args): number;
getStackSequence?(context: Context, args: Args): number | null; getStackSequence?(context: Context, args: Args): number | null;
getProgressText?(context: Context, args: Args, term: number, termMax: number): string; getProgressText?(context: Context, args: Args, term: number, termMax: number): string;
@@ -179,6 +179,10 @@ export class ActionDefinition<
return (calcDistance(context.nation.capitalCityId, args.destCityID, context.map, allowedCityIds) ?? 0) * 2; return (calcDistance(context.nation.capitalCityId, args.destCityID, context.map, allowedCityIds) ?? 0) * 2;
} }
getTurnDurationHint(): string {
return '1+거리×2턴';
}
getStackSequence(context: MoveCapitalResolveContext<TriggerState>): number { getStackSequence(context: MoveCapitalResolveContext<TriggerState>): number {
const value = context.nation?.meta.capset; const value = context.nation?.meta.capset;
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0; return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;