merge: 최신 main 변경을 내 정보 전투 통계 작업에 통합
This commit is contained in:
@@ -69,7 +69,31 @@ export const do징병 = (ai: GeneralAI) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const generalMeta = asRecord(ai.general.meta);
|
const generalMeta = asRecord(ai.general.meta);
|
||||||
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
|
const recruitContext = {
|
||||||
|
general: ai.general,
|
||||||
|
nation,
|
||||||
|
...(ai.worldRef
|
||||||
|
? {
|
||||||
|
worldView: {
|
||||||
|
listGenerals: () => ai.worldRef!.listGenerals(),
|
||||||
|
listGeneralsByCity: (cityId: number) =>
|
||||||
|
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
||||||
|
listNations: () => ai.worldRef!.listNations(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
time: {
|
||||||
|
year: ai.world.currentYear,
|
||||||
|
month: ai.world.currentMonth,
|
||||||
|
startYear: ai.startYear,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
|
||||||
|
// The cached AI stat follows the scenario/global classification cap, while
|
||||||
|
// che_징병 resolves the actual command capacity from the general's current
|
||||||
|
// stat and modules. NPC recruitment must request that same uncapped amount;
|
||||||
|
// otherwise a 300-leadership general can be stuck at a 100/140/255 cap.
|
||||||
|
const fullLeadership = recruitment.resolveFullLeadership(recruitContext);
|
||||||
trace('population-policy', {
|
trace('population-policy', {
|
||||||
population: city.population,
|
population: city.population,
|
||||||
populationMax: city.populationMax,
|
populationMax: city.populationMax,
|
||||||
@@ -175,26 +199,6 @@ export const do징병 = (ai: GeneralAI) => {
|
|||||||
// whether to halve the requested crew. In particular, that command caps
|
// whether to halve the requested crew. In particular, that command caps
|
||||||
// the charge at the actually refillable amount when the selected type is
|
// the charge at the actually refillable amount when the selected type is
|
||||||
// already equipped, then applies traits/items and legacy rounding.
|
// already equipped, then applies traits/items and legacy rounding.
|
||||||
const recruitContext = {
|
|
||||||
general: ai.general,
|
|
||||||
nation,
|
|
||||||
...(ai.worldRef
|
|
||||||
? {
|
|
||||||
worldView: {
|
|
||||||
listGenerals: () => ai.worldRef!.listGenerals(),
|
|
||||||
listGeneralsByCity: (cityId: number) =>
|
|
||||||
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
|
||||||
listNations: () => ai.worldRef!.listNations(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
time: {
|
|
||||||
year: ai.world.currentYear,
|
|
||||||
month: ai.world.currentMonth,
|
|
||||||
startYear: ai.startYear,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
|
|
||||||
const goldCost = recruitment.getCost(recruitContext, crewTypeId, crewAmount, picked).gold;
|
const goldCost = recruitment.getCost(recruitContext, crewTypeId, crewAmount, picked).gold;
|
||||||
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
|
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
|
||||||
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
|
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
|
||||||
|
|||||||
@@ -955,6 +955,57 @@ describe('legacy NPC AI final-decision parity', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[101, 100, 10_100],
|
||||||
|
[140, 100, 14_000],
|
||||||
|
[256, 255, 25_600],
|
||||||
|
[300, 140, 30_000],
|
||||||
|
[300, 255, 30_000],
|
||||||
|
])(
|
||||||
|
'requests the command-resolved full crew above the cached AI cap (leadership=%i, cached=%i)',
|
||||||
|
(leadership, cachedLeadership, amount) => {
|
||||||
|
const ai = makeAi({
|
||||||
|
dipState: 2,
|
||||||
|
city: { population: 100_000, populationMax: 100_000 },
|
||||||
|
general: {
|
||||||
|
stats: { leadership, strength: 70, intelligence: 70 },
|
||||||
|
gold: 100_000,
|
||||||
|
rice: 100_000,
|
||||||
|
meta: { killturn: 100, fullLeadership: cachedLeadership, rank_killcrew: 0, rank_deathcrew: 1 },
|
||||||
|
},
|
||||||
|
rng: makeRng([], [0, 0]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(do징병(ai)).toMatchObject({
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1, amount },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('uses recruitment stat modules when resolving uncapped NPC crew capacity', () => {
|
||||||
|
const ai = makeAi({
|
||||||
|
dipState: 2,
|
||||||
|
city: { population: 100_000, populationMax: 100_000 },
|
||||||
|
general: {
|
||||||
|
stats: { leadership: 240, strength: 70, intelligence: 70 },
|
||||||
|
gold: 100_000,
|
||||||
|
rice: 100_000,
|
||||||
|
meta: { killturn: 100, fullLeadership: 100, rank_killcrew: 0, rank_deathcrew: 1 },
|
||||||
|
},
|
||||||
|
generalActionModules: singleActionModuleStack({
|
||||||
|
eventHandlers: {},
|
||||||
|
onCalcStat: (_context, statName, value) => (statName === 'leadership' ? value * 1.25 : value),
|
||||||
|
}),
|
||||||
|
rng: makeRng([], [0, 0]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(do징병(ai)).toMatchObject({
|
||||||
|
action: 'che_징병',
|
||||||
|
args: { crewType: 1, amount: 30_000 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('uses the refillable same-type crew amount for the legacy gold-cost halving threshold', () => {
|
it('uses the refillable same-type crew amount for the legacy gold-cost halving threshold', () => {
|
||||||
const ai = makeAi({
|
const ai = makeAi({
|
||||||
dipState: 2,
|
dipState: 2,
|
||||||
|
|||||||
@@ -2291,6 +2291,7 @@ test('keeps Ref command briefs and autonomous-action state after a turn mutation
|
|||||||
|
|
||||||
test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => {
|
test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => {
|
||||||
const requests = await install(page);
|
const requests = await install(page);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
const editor = page.locator('[data-command-scope="general"]');
|
const editor = page.locator('[data-command-scope="general"]');
|
||||||
@@ -2318,6 +2319,41 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
|
|||||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||||
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행');
|
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행');
|
||||||
|
|
||||||
|
const recentMenu = editor.locator('details').filter({ has: page.getByText('최근 실행', { exact: true }) });
|
||||||
|
await recentMenu.locator('summary').click();
|
||||||
|
const recentBriefButton = recentMenu.getByRole('button', { name: '【허창】에 화계실행', exact: true });
|
||||||
|
await expect(recentBriefButton).toBeVisible();
|
||||||
|
await recentBriefButton.hover();
|
||||||
|
await page.screenshot({
|
||||||
|
path: test.info().outputPath('advanced-recent-command-brief-desktop-1200.png'),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
await recentMenu.locator('summary').click();
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await recentMenu.locator('summary').click();
|
||||||
|
await expect(recentBriefButton).toBeVisible();
|
||||||
|
await recentBriefButton.focus();
|
||||||
|
await expect(recentBriefButton).toBeFocused();
|
||||||
|
const mobileRecentGeometry = await editor.evaluate((element) => {
|
||||||
|
const menu = element.querySelector<HTMLElement>('details[open] .menu-items');
|
||||||
|
const recentButton = menu?.querySelector<HTMLElement>('button');
|
||||||
|
if (!menu || !recentButton) throw new Error('advanced recent command menu is missing');
|
||||||
|
return {
|
||||||
|
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
||||||
|
menuRight: menu.getBoundingClientRect().right,
|
||||||
|
buttonRight: recentButton.getBoundingClientRect().right,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(mobileRecentGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||||||
|
expect(mobileRecentGeometry.menuRight).toBeLessThanOrEqual(500);
|
||||||
|
expect(mobileRecentGeometry.buttonRight).toBeLessThanOrEqual(500);
|
||||||
|
await page.screenshot({
|
||||||
|
path: test.info().outputPath('advanced-recent-command-brief-mobile-500.png'),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
await recentMenu.locator('summary').click();
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
|
||||||
await drag(0, 2);
|
await drag(0, 2);
|
||||||
await editor.locator('details.selected-menu > summary').click();
|
await editor.locator('details.selected-menu > summary').click();
|
||||||
await editor.getByRole('button', { name: '복사하기', exact: true }).click();
|
await editor.getByRole('button', { name: '복사하기', exact: true }).click();
|
||||||
|
|||||||
@@ -142,11 +142,12 @@ const isRecruitmentCommand = computed(
|
|||||||
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
|
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
|
||||||
);
|
);
|
||||||
const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value);
|
const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value);
|
||||||
const rowLabel = (row: ReservedCommandRow): string =>
|
const commandBrief = (entry: { action: string; args: unknown; label?: string }): string =>
|
||||||
formatReservedCommandBrief(props.scope, row.action, row.args, props.commandTable) ||
|
formatReservedCommandBrief(props.scope, entry.action, entry.args, props.commandTable) ||
|
||||||
row.label ||
|
entry.label ||
|
||||||
labelMap.value.get(row.action) ||
|
labelMap.value.get(entry.action) ||
|
||||||
row.action;
|
entry.action;
|
||||||
|
const rowLabel = (row: ReservedCommandRow): string => commandBrief(row);
|
||||||
const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length);
|
const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length);
|
||||||
const pattern = () => extractPattern(props.rows, selectedIndices());
|
const pattern = () => extractPattern(props.rows, selectedIndices());
|
||||||
const touchMenus = () => (menuRevision.value += 1);
|
const touchMenus = () => (menuRevision.value += 1);
|
||||||
@@ -467,7 +468,7 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
clickOutsideMenu($event);
|
clickOutsideMenu($event);
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ entry.label ?? labelMap.get(entry.action) ?? entry.action }}
|
{{ commandBrief(entry) }}
|
||||||
</button>
|
</button>
|
||||||
<span v-if="!storage?.recent.size" class="empty-menu">비어 있음</span>
|
<span v-if="!storage?.recent.size" class="empty-menu">비어 있음</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user