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