feat: 사령부 명령 필요 턴을 표시한다
Ref처럼 여러 턴이 필요한 국가 명령의 소요 턴을 명령명 아래에 표시한다. 실행 getPreReqTurn을 기준으로 삼고 천도는 거리 기반 공식을 노출한다.
This commit is contained in:
@@ -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: [],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user