다중 턴 명령 쿨타임을 불가로 표시

This commit is contained in:
2026-08-31 08:35:46 +00:00
parent 29014e9c2d
commit 898d756036
4 changed files with 185 additions and 5 deletions
+49 -4
View File
@@ -650,6 +650,40 @@ const evaluateDefinition = (
return evaluateAvailability(constraints, ctx, view, reqArg); return evaluateAvailability(constraints, ctx, view, reqArg);
}; };
const readNextAvailableTurn = (meta: Readonly<Record<string, unknown>>, actionName: string): number | null => {
const raw = meta[`next_execute_${actionName}`];
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
if (typeof raw === 'string') {
const parsed = Number(raw);
return Number.isFinite(parsed) ? Math.floor(parsed) : null;
}
return null;
};
const evaluateCooldown = (
definition: GeneralActionDefinition,
scope: 'general' | 'nation',
ctx: ConstraintContext,
view: StateView,
currentYearMonth: number
): AvailabilityCore | null => {
const owner =
scope === 'general'
? (view.get({ kind: 'general', id: ctx.actorId }) as General | null)
: ctx.nationId === undefined
? null
: (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null);
if (!owner) return null;
const nextAvailableTurn = readNextAvailableTurn(owner.meta, definition.name);
if (nextAvailableTurn === null || currentYearMonth >= nextAvailableTurn) return null;
return {
possible: false,
status: 'blocked',
reason: `${nextAvailableTurn - currentYearMonth}턴 더 기다려야 합니다`,
};
};
const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore => const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore =>
AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs; AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs;
@@ -788,14 +822,20 @@ const buildGroups = (
ctx: ConstraintContext, ctx: ConstraintContext,
view: StateView, view: StateView,
includeTurnDuration = false, includeTurnDuration = false,
env: CommandEnv env: CommandEnv,
scope: 'general' | 'nation',
currentYearMonth: number
): TurnCommandGroup[] => { ): 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 baseAvailability = 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 availability =
baseAvailability.status === 'blocked' || baseAvailability.status === 'unknown'
? baseAvailability
: (evaluateCooldown(entry.definition, scope, ctx, view, currentYearMonth) ?? baseAvailability);
const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined; const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined;
const costText = getCommandCostText(entry, ctx, view, env); const costText = getCommandCostText(entry, ctx, view, env);
const value: TurnCommandAvailability = { const value: TurnCommandAvailability = {
@@ -900,6 +940,7 @@ export const buildTurnCommandTable = async (options: {
currentYear: options.worldState.currentYear, currentYear: options.worldState.currentYear,
currentMonth: options.worldState.currentMonth, currentMonth: options.worldState.currentMonth,
}); });
const currentYearMonth = options.worldState.currentYear * 12 + options.worldState.currentMonth - 1;
return { return {
general: buildGroups( general: buildGroups(
@@ -907,14 +948,18 @@ export const buildTurnCommandTable = async (options: {
ctx, ctx,
view, view,
false, false,
env env,
'general',
currentYearMonth
), ),
nation: buildGroups( nation: buildGroups(
projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS), projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS),
ctx, ctx,
view, view,
true, true,
env env,
'nation',
currentYearMonth
), ),
inputOptions: options.inputOptions ?? { inputOptions: options.inputOptions ?? {
cities: [], cities: [],
+77
View File
@@ -102,6 +102,9 @@ const buildNation = (): NationRow =>
}) as unknown as NationRow; }) as unknown as NationRow;
describe('buildTurnCommandTable', () => { describe('buildTurnCommandTable', () => {
const findCommand = (table: Awaited<ReturnType<typeof buildTurnCommandTable>>, key: string) =>
[...table.general, ...table.nation].flatMap(({ values }) => values).find((command) => command.key === key);
it('projects the general and chief reserved-turn categories and command order from Ref', async () => { it('projects the general and chief reserved-turn categories and command order from Ref', async () => {
const table = await buildTurnCommandTable({ const table = await buildTurnCommandTable({
worldState: buildWorldState(), worldState: buildWorldState(),
@@ -286,6 +289,80 @@ describe('buildTurnCommandTable', () => {
}); });
}); });
it('blocks both speciality resets while their cooldown remains and opens them at the boundary month', async () => {
const cooldownGeneral = {
...buildGeneral(),
specialCode: 'che_상재',
special2Code: 'che_신산',
meta: {
killturn: 24,
'next_execute_내정 특기 초기화': 36,
'next_execute_전투 특기 초기화': '37',
},
} as GeneralRow;
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: cooldownGeneral,
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
expect(findCommand(table, 'che_내정특기초기화')).toMatchObject({
possible: true,
status: 'available',
});
expect(findCommand(table, 'che_전투특기초기화')).toMatchObject({
possible: false,
status: 'blocked',
reason: '1턴 더 기다려야 합니다',
});
});
it('keeps the missing-speciality reason ahead of a remaining reset cooldown', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: {
...buildGeneral(),
meta: {
killturn: 24,
'next_execute_내정 특기 초기화': 37,
'next_execute_전투 특기 초기화': 37,
},
} as GeneralRow,
city: buildCity(),
nation: buildNation(),
nationGenerals: null,
});
for (const key of ['che_내정특기초기화', 'che_전투특기초기화']) {
expect(findCommand(table, key)).toMatchObject({
possible: false,
status: 'blocked',
reason: '특기가 없습니다.',
});
}
});
it('projects a multi-turn nation command cooldown before target input', async () => {
const table = await buildTurnCommandTable({
worldState: buildWorldState(),
general: buildGeneral(),
city: buildCity(),
nation: {
...buildNation(),
meta: { next_execute_피장파장: 37 },
} as NationRow,
nationGenerals: null,
});
expect(findCommand(table, 'che_피장파장')).toMatchObject({
possible: false,
status: 'blocked',
reason: '1턴 더 기다려야 합니다',
});
});
it('projects costs from the current world develcost used by command execution', async () => { it('projects costs from the current world develcost used by command execution', async () => {
const worldState = buildWorldState(); const worldState = buildWorldState();
(worldState as unknown as { meta: Record<string, unknown> }).meta.develcost = 120; (worldState as unknown as { meta: Record<string, unknown> }).meta.develcost = 120;
@@ -429,6 +429,38 @@ describe('legacy general-turn execution contract', () => {
expect(updated.meta.prev_types_special2).toEqual(['che_격노']); expect(updated.meta.prev_types_special2).toEqual(['che_격노']);
}); });
it('rejects a speciality reset cooldown before accumulating its first preparation turn', async () => {
const general = makeGeneral({
role: {
personality: null,
specialDomestic: null,
specialWar: 'che_격노',
items: { horse: null, weapon: null, book: null, item: null },
},
meta: { killturn: 24, 'next_execute_전투 특기 초기화': 2460 },
});
const harness = await createTurnTestHarness({
snapshot: makeSnapshot(general),
state: makeState(),
schedule,
map,
collectLogs: true,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_전투특기초기화', args: {} };
await harness.runOneTick();
const updated = harness.world.getGeneralById(1)!;
expect(updated.role.specialWar).toBe('che_격노');
expect(updated.lastTurn).not.toEqual({ command: '전투 특기 초기화', term: 1 });
expect(harness.getCollectedLogs()).toContainEqual(
expect.objectContaining({ text: expect.stringContaining('60턴 더 기다려야 합니다') })
);
expect(harness.getCollectedLogs()).not.toContainEqual(
expect.objectContaining({ text: expect.stringContaining('새로운 적성을 찾는 중') })
);
});
it('preserves the legacy battle-readiness term reset instead of making its reward reachable', async () => { it('preserves the legacy battle-readiness term reset instead of making its reward reachable', async () => {
const general = makeGeneral({ crew: 1_000, train: 40, atmos: 40 }); const general = makeGeneral({ crew: 1_000, train: 40, atmos: 40 });
const harness = await createTurnTestHarness({ const harness = await createTurnTestHarness({
+27 -1
View File
@@ -1207,7 +1207,7 @@ test('defaults founding to a Ref-selectable nation trait and opens colored optio
await expect.poll(() => JSON.stringify(requests)).toContain('"colorType":15'); await expect.poll(() => JSON.stringify(requests)).toContain('"colorType":15');
}); });
test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => { test('shows speciality reset cooldowns and reserves special user commands from the picker', async ({ page }) => {
const specialCommandTable = { const specialCommandTable = {
general: [ general: [
{ {
@@ -1222,6 +1222,24 @@ test('reserves force move, retirement, and resignation from the user command pic
reason: '나이가 60세 이상이어야 합니다.', reason: '나이가 60세 이상이어야 합니다.',
inputFields: [], inputFields: [],
}, },
{
key: 'che_내정특기초기화',
name: '내정 특기 초기화',
reqArg: false,
possible: false,
status: 'blocked',
reason: '12턴 더 기다려야 합니다',
inputFields: [],
},
{
key: 'che_전투특기초기화',
name: '전투 특기 초기화',
reqArg: false,
possible: false,
status: 'blocked',
reason: '24턴 더 기다려야 합니다',
inputFields: [],
},
], ],
}, },
{ {
@@ -1273,6 +1291,12 @@ test('reserves force move, retirement, and resignation from the user command pic
const retirement = picker.getByRole('button', { name: '은퇴', exact: true }); const retirement = picker.getByRole('button', { name: '은퇴', exact: true });
await expect(retirement).toHaveClass(/blocked/); await expect(retirement).toHaveClass(/blocked/);
await expect(retirement).toHaveAttribute('title', '나이가 60세 이상이어야 합니다.'); await expect(retirement).toHaveAttribute('title', '나이가 60세 이상이어야 합니다.');
const domesticReset = picker.getByRole('button', { name: '내정 특기 초기화', exact: true });
const warReset = picker.getByRole('button', { name: '전투 특기 초기화', exact: true });
await expect(domesticReset).toHaveClass(/blocked/);
await expect(domesticReset).toHaveAttribute('title', '12턴 더 기다려야 합니다');
await expect(warReset).toHaveClass(/blocked/);
await expect(warReset).toHaveAttribute('title', '24턴 더 기다려야 합니다');
await retirement.hover(); await retirement.hover();
await retirement.focus(); await retirement.focus();
await expect(retirement).toBeFocused(); await expect(retirement).toBeFocused();
@@ -1315,6 +1339,8 @@ test('reserves force move, retirement, and resignation from the user command pic
expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3); expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3);
await picker.getByRole('button', { name: '개인', exact: true }).click(); await picker.getByRole('button', { name: '개인', exact: true }).click();
await expect(picker.getByRole('button', { name: '은퇴', exact: true })).toBeVisible(); await expect(picker.getByRole('button', { name: '은퇴', exact: true })).toBeVisible();
await expect(picker.getByRole('button', { name: '내정 특기 초기화', exact: true })).toHaveClass(/blocked/);
await expect(picker.getByRole('button', { name: '전투 특기 초기화', exact: true })).toHaveClass(/blocked/);
await picker.screenshot({ path: test.info().outputPath('special-user-commands-mobile-500.png') }); await picker.screenshot({ path: test.info().outputPath('special-user-commands-mobile-500.png') });
}); });