merge: 사령부 필요 턴 표시를 통합한다

This commit is contained in:
2026-08-25 04:15:19 +00:00
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 {
key: string;
name: string;
turnDurationText?: string;
reqArg: boolean;
possible: boolean;
status: AvailabilityStatus;
@@ -699,16 +700,34 @@ const buildEntries = (
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[]>();
for (const entry of entries) {
const availability = entry.evaluate
? entry.evaluate(ctx, view)
: evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs);
const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined;
const value: TurnCommandAvailability = {
key: entry.definition.key,
name: entry.definition.name,
...(turnDurationText ? { turnDurationText } : {}),
reqArg: entry.reqArg,
inputFields: entry.inputFields,
...availability,
@@ -806,7 +825,12 @@ export const buildTurnCommandTable = async (options: {
ctx,
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 ?? {
cities: [],
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 () => {
@@ -225,6 +246,14 @@ describe('buildTurnCommandTable', () => {
'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 () => {
+38 -11
View File
@@ -320,9 +320,10 @@ const buildGeneralCommand = (key: string, name: string) => ({
{ key: 'destGeneralId', label: '대상 장수', kind: 'select', required: true, optionSource: 'generals' },
],
});
const buildSimpleCommand = (key: string, name: string) => ({
const buildSimpleCommand = (key: string, name: string, turnDurationText?: string) => ({
key,
name,
...(turnDurationText ? { turnDurationText } : {}),
reqArg: false,
possible: true,
status: 'available',
@@ -635,23 +636,23 @@ const refChiefCommandTable = {
{
category: '특수',
values: [
buildSimpleCommand('che_초토화', '초토화'),
buildSimpleCommand('che_천도', '천도'),
buildSimpleCommand('che_증축', '증축'),
buildSimpleCommand('che_감축', '감축'),
buildSimpleCommand('che_초토화', '초토화', '3턴'),
buildSimpleCommand('che_천도', '천도', '1+거리×2턴'),
buildSimpleCommand('che_증축', '증축', '6턴'),
buildSimpleCommand('che_감축', '감축', '6턴'),
],
},
{
category: '전략',
values: [
buildSimpleCommand('che_필사즉생', '필사즉생'),
buildSimpleCommand('che_필사즉생', '필사즉생', '3턴'),
buildSimpleCommand('che_백성동원', '백성동원'),
buildSimpleCommand('che_수몰', '수몰'),
buildSimpleCommand('che_허보', '허보'),
buildSimpleCommand('che_의병모집', '의병모집'),
buildSimpleCommand('che_수몰', '수몰', '3턴'),
buildSimpleCommand('che_허보', '허보', '2턴'),
buildSimpleCommand('che_의병모집', '의병모집', '3턴'),
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));
for (const [category, commands] of Object.entries(expected)) {
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 });
await rename.hover();
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.horizontalOverflow).toBeLessThanOrEqual(0);
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') });
});
@@ -63,6 +63,7 @@ export type CommandInputField = {
export type CommandAvailability = {
key: string;
name: string;
turnDurationText?: string;
reqArg: boolean;
status: 'available' | 'blocked' | 'needsInput' | 'unknown';
possible: boolean;
@@ -5,6 +5,7 @@ import SkeletonLines from '../ui/SkeletonLines.vue';
type CommandAvailability = {
key: string;
name: string;
turnDurationText?: string;
reqArg: boolean;
status: 'available' | 'blocked' | 'needsInput' | 'unknown';
possible: boolean;
@@ -161,6 +162,9 @@ const commandTitle = (command: CommandAvailability) =>
@click="emit('select', command.key)"
>
<span class="command-name">{{ command.name }}</span>
<small v-if="command.turnDurationText" class="command-duration">
/{{ command.turnDurationText }}
</small>
</button>
</div>
</div>
@@ -210,6 +214,7 @@ const commandTitle = (command: CommandAvailability) =>
min-height: 0;
padding-inline: 5px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
@@ -236,6 +241,14 @@ const commandTitle = (command: CommandAvailability) =>
font-weight: 600;
}
.command-duration {
display: block;
font-size: 0.875em;
font-weight: 400;
line-height: 1.1;
white-space: nowrap;
}
.empty {
color: rgba(232, 221, 196, 0.6);
}
+3 -6
View File
@@ -16,14 +16,11 @@ export interface GeneralActionDefinition<
// 커맨드 입력 단계에서 최소 조건만 평가할 때 사용한다.
buildMinConstraints?(ctx: ConstraintContext, args: Args): Constraint[];
buildConstraints(ctx: ConstraintContext, args: Args): Constraint[];
formatConstraintFailure?(
reason: string,
ctx: ConstraintContext,
args: Args,
view: StateView
): string | null;
formatConstraintFailure?(reason: string, ctx: ConstraintContext, args: Args, view: StateView): string | null;
// NationCommand::addTermStack()/setNextAvailable() 호환 실행 메타데이터.
getPreReqTurn?(context: Context, args: Args): number;
// 입력 시점에 대상이 정해져야 소요 턴을 계산할 수 있는 명령의 Ref 표시 문구.
getTurnDurationHint?(): string;
getPostReqTurn?(context: Context, args: Args): number;
getStackSequence?(context: Context, args: Args): number | null;
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;
}
getTurnDurationHint(): string {
return '1+거리×2턴';
}
getStackSequence(context: MoveCapitalResolveContext<TriggerState>): number {
const value = context.nation?.meta.capset;
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;