merge: 최신 main을 서버 시계와 첫 턴 수정에 통합
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
|||||||
GeneralItemSlots,
|
GeneralItemSlots,
|
||||||
GeneralActionDefinition,
|
GeneralActionDefinition,
|
||||||
GeneralTurnCommandSpec,
|
GeneralTurnCommandSpec,
|
||||||
|
GeneralTurnCommandKey,
|
||||||
MapDefinition,
|
MapDefinition,
|
||||||
Nation,
|
Nation,
|
||||||
NationTurnCommandSpec,
|
NationTurnCommandSpec,
|
||||||
@@ -72,6 +73,83 @@ interface CommandEntry {
|
|||||||
evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore;
|
evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REF_GENERAL_COMMAND_GROUPS = [
|
||||||
|
{
|
||||||
|
category: '개인',
|
||||||
|
commands: [
|
||||||
|
'휴식',
|
||||||
|
'che_요양',
|
||||||
|
'che_단련',
|
||||||
|
'che_숙련전환',
|
||||||
|
'che_견문',
|
||||||
|
'che_은퇴',
|
||||||
|
'che_장비매매',
|
||||||
|
'che_군량매매',
|
||||||
|
'che_내정특기초기화',
|
||||||
|
'che_전투특기초기화',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '내정',
|
||||||
|
commands: [
|
||||||
|
'che_농지개간',
|
||||||
|
'che_상업투자',
|
||||||
|
'che_기술연구',
|
||||||
|
'che_수비강화',
|
||||||
|
'che_성벽보수',
|
||||||
|
'che_치안강화',
|
||||||
|
'che_정착장려',
|
||||||
|
'che_주민선정',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '군사',
|
||||||
|
commands: [
|
||||||
|
'che_징병',
|
||||||
|
'che_모병',
|
||||||
|
'che_훈련',
|
||||||
|
'che_사기진작',
|
||||||
|
'che_출병',
|
||||||
|
'che_집합',
|
||||||
|
'che_소집해제',
|
||||||
|
'che_첩보',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '인사',
|
||||||
|
commands: [
|
||||||
|
'che_이동',
|
||||||
|
'che_강행',
|
||||||
|
'che_인재탐색',
|
||||||
|
'che_등용',
|
||||||
|
'che_귀환',
|
||||||
|
'che_임관',
|
||||||
|
'che_랜덤임관',
|
||||||
|
'che_장수대상임관',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '계략',
|
||||||
|
commands: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '국가',
|
||||||
|
commands: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
|
||||||
|
},
|
||||||
|
] as const satisfies ReadonlyArray<{
|
||||||
|
category: string;
|
||||||
|
commands: ReadonlyArray<GeneralTurnCommandKey>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const REF_GENERAL_CATEGORY_ORDER = new Map<string, number>(
|
||||||
|
REF_GENERAL_COMMAND_GROUPS.map(({ category }, index) => [category, index] as const)
|
||||||
|
);
|
||||||
|
const REF_GENERAL_COMMAND_POSITION = new Map<string, { category: string; index: number }>(
|
||||||
|
REF_GENERAL_COMMAND_GROUPS.flatMap(({ category, commands }) =>
|
||||||
|
commands.map((command, index) => [command, { category, index }] as const)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const INPUT_REQUIREMENT_KINDS = new Set<RequirementKey['kind']>([
|
const INPUT_REQUIREMENT_KINDS = new Set<RequirementKey['kind']>([
|
||||||
'destGeneral',
|
'destGeneral',
|
||||||
'destCity',
|
'destCity',
|
||||||
@@ -567,6 +645,26 @@ const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: Stat
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const projectRefGeneralCommandGroups = (entries: CommandEntry[]): CommandEntry[] =>
|
||||||
|
entries
|
||||||
|
.map((entry, profileIndex) => {
|
||||||
|
const refPosition = REF_GENERAL_COMMAND_POSITION.get(entry.definition.key as GeneralTurnCommandKey);
|
||||||
|
return {
|
||||||
|
entry: refPosition ? { ...entry, category: refPosition.category } : entry,
|
||||||
|
categoryIndex:
|
||||||
|
REF_GENERAL_CATEGORY_ORDER.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER,
|
||||||
|
commandIndex: refPosition?.index ?? profileIndex,
|
||||||
|
profileIndex,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.categoryIndex - right.categoryIndex ||
|
||||||
|
left.commandIndex - right.commandIndex ||
|
||||||
|
left.profileIndex - right.profileIndex
|
||||||
|
)
|
||||||
|
.map(({ entry }) => entry);
|
||||||
|
|
||||||
export const buildTurnCommandTable = async (options: {
|
export const buildTurnCommandTable = async (options: {
|
||||||
worldState: WorldStateRow;
|
worldState: WorldStateRow;
|
||||||
general: GeneralRow;
|
general: GeneralRow;
|
||||||
@@ -598,7 +696,7 @@ export const buildTurnCommandTable = async (options: {
|
|||||||
const nationEntries = buildEntries(env, nationSpecs);
|
const nationEntries = buildEntries(env, nationSpecs);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
general: buildGroups(generalEntries, ctx, view),
|
general: buildGroups(projectRefGeneralCommandGroups(generalEntries), ctx, view),
|
||||||
nation: buildGroups(nationEntries, ctx, view),
|
nation: buildGroups(nationEntries, ctx, view),
|
||||||
inputOptions: options.inputOptions ?? {
|
inputOptions: options.inputOptions ?? {
|
||||||
cities: [],
|
cities: [],
|
||||||
|
|||||||
@@ -100,6 +100,47 @@ const buildNation = (): NationRow =>
|
|||||||
}) as unknown as NationRow;
|
}) as unknown as NationRow;
|
||||||
|
|
||||||
describe('buildTurnCommandTable', () => {
|
describe('buildTurnCommandTable', () => {
|
||||||
|
it('projects the main reserved-turn categories and command order from Ref', async () => {
|
||||||
|
const table = await buildTurnCommandTable({
|
||||||
|
worldState: buildWorldState(),
|
||||||
|
general: buildGeneral(),
|
||||||
|
city: buildCity(),
|
||||||
|
nation: buildNation(),
|
||||||
|
nationGenerals: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(table.general.map(({ category }) => category)).toEqual(['개인', '내정', '군사', '인사', '계략', '국가']);
|
||||||
|
expect(
|
||||||
|
Object.fromEntries(table.general.map(({ category, values }) => [category, values.map(({ key }) => key)]))
|
||||||
|
).toEqual({
|
||||||
|
개인: [
|
||||||
|
'휴식',
|
||||||
|
'che_요양',
|
||||||
|
'che_단련',
|
||||||
|
'che_숙련전환',
|
||||||
|
'che_견문',
|
||||||
|
'che_장비매매',
|
||||||
|
'che_군량매매',
|
||||||
|
'che_내정특기초기화',
|
||||||
|
'che_전투특기초기화',
|
||||||
|
],
|
||||||
|
내정: [
|
||||||
|
'che_농지개간',
|
||||||
|
'che_상업투자',
|
||||||
|
'che_기술연구',
|
||||||
|
'che_수비강화',
|
||||||
|
'che_성벽보수',
|
||||||
|
'che_치안강화',
|
||||||
|
'che_정착장려',
|
||||||
|
'che_주민선정',
|
||||||
|
],
|
||||||
|
군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'],
|
||||||
|
인사: ['che_이동', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'],
|
||||||
|
계략: ['che_화계'],
|
||||||
|
국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('uses min-condition constraints for availability', async () => {
|
it('uses min-condition constraints for availability', async () => {
|
||||||
const table = await buildTurnCommandTable({
|
const table = await buildTurnCommandTable({
|
||||||
worldState: buildWorldState(),
|
worldState: buildWorldState(),
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ const install = async (
|
|||||||
mode: 'member' | 'wanderer' | 'admin' = 'member',
|
mode: 'member' | 'wanderer' | 'admin' = 'member',
|
||||||
trade: number | null = 100,
|
trade: number | null = 100,
|
||||||
globalNationCount = 2,
|
globalNationCount = 2,
|
||||||
mapFixture = map
|
mapFixture = map,
|
||||||
|
denseCurrentCity = false
|
||||||
) => {
|
) => {
|
||||||
await page.addInitScript((profile) => {
|
await page.addInitScript((profile) => {
|
||||||
localStorage.setItem('sammo-game-token', 'ga_info');
|
localStorage.setItem('sammo-game-token', 'ga_info');
|
||||||
@@ -372,8 +373,32 @@ const install = async (
|
|||||||
crew: 500,
|
crew: 500,
|
||||||
train: 90,
|
train: 90,
|
||||||
atmos: 90,
|
atmos: 90,
|
||||||
turns: ['징병'],
|
turns: denseCurrentCity ? ['징병', '훈련'] : ['징병'],
|
||||||
},
|
},
|
||||||
|
...(denseCurrentCity
|
||||||
|
? Array.from({ length: 12 }, (_, index) => ({
|
||||||
|
id: index + 2,
|
||||||
|
name: `NPC장수이름이긴${index + 1}`,
|
||||||
|
npcState: 2,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
nationId: 1,
|
||||||
|
nationName: '아국',
|
||||||
|
leadership: 60,
|
||||||
|
strength: 60,
|
||||||
|
intelligence: 60,
|
||||||
|
injury: 0,
|
||||||
|
officerLevel: 1,
|
||||||
|
leadershipBonus: 0,
|
||||||
|
defenceTrain: 80,
|
||||||
|
crewTypeId: 1,
|
||||||
|
crewTypeName: '보병',
|
||||||
|
crew: 500,
|
||||||
|
train: 90,
|
||||||
|
atmos: 90,
|
||||||
|
turns: [],
|
||||||
|
}))
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
forceSummary: {
|
forceSummary: {
|
||||||
enemyCrew: 0,
|
enemyCrew: 0,
|
||||||
@@ -407,7 +432,7 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p
|
|||||||
['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'],
|
['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'],
|
||||||
['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'],
|
['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'],
|
||||||
['global-info', '.global-page', '14px', 'Pretendard', 'collapse'],
|
['global-info', '.global-page', '14px', 'Pretendard', 'collapse'],
|
||||||
['current-city', '.city-page', '16px', 'Times New Roman', 'separate'],
|
['current-city', '.city-page', '14px', 'Pretendard', 'separate'],
|
||||||
] as const) {
|
] as const) {
|
||||||
await go(page, path);
|
await go(page, path);
|
||||||
await expect(page.locator(selector)).toBeVisible();
|
await expect(page.locator(selector)).toBeVisible();
|
||||||
@@ -774,8 +799,8 @@ test('current-city exposes own general details to a member and admin fixture', a
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 });
|
expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 });
|
||||||
expect(legacyGeometry.stats).toEqual({ x: 100, y: 178, width: 1000, height: 136 });
|
expect(legacyGeometry.stats).toEqual({ x: 100, y: 165.375, width: 1000, height: 106.9375 });
|
||||||
expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 332, width: 1024 });
|
expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 290.3125, width: 1024 });
|
||||||
expect(legacyGeometry.titleAlign).toBe('start');
|
expect(legacyGeometry.titleAlign).toBe('start');
|
||||||
expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 });
|
expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 });
|
||||||
if (artifactRoot) {
|
if (artifactRoot) {
|
||||||
@@ -834,6 +859,67 @@ test('current-city exposes own general details to a member and admin fixture', a
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('current-city wraps dense general names and only shrinks reserved turns', async ({ page }) => {
|
||||||
|
await install(page, 'member', 100, 2, map, true);
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'desktop', width: 1200, height: 900 },
|
||||||
|
{ name: 'mobile', width: 500, height: 900 },
|
||||||
|
]) {
|
||||||
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||||
|
await go(page, 'current-city');
|
||||||
|
|
||||||
|
const pageStyle = await page.locator('.city-page').evaluate((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return { fontFamily: style.fontFamily, fontSize: style.fontSize };
|
||||||
|
});
|
||||||
|
expect(pageStyle.fontFamily).toContain('Pretendard');
|
||||||
|
expect(pageStyle.fontSize).toBe('14px');
|
||||||
|
|
||||||
|
const names = page.locator('.general-names');
|
||||||
|
await expect(names).toHaveCSS('white-space', 'normal');
|
||||||
|
const nameLineCount = await names.locator('span').evaluateAll((elements) => {
|
||||||
|
const tops = elements.map((element) => Math.round(element.getBoundingClientRect().top));
|
||||||
|
return new Set(tops).size;
|
||||||
|
});
|
||||||
|
expect(nameLineCount).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
const rows = page.locator('.generals tbody tr');
|
||||||
|
const reservedTurns = rows.nth(0).locator('.turns');
|
||||||
|
const npcTurns = rows.nth(1).locator('.turns');
|
||||||
|
await expect(reservedTurns).toContainText('1 : 징병');
|
||||||
|
await expect(reservedTurns).toContainText('2 : 훈련');
|
||||||
|
await expect(reservedTurns).toHaveClass(/turns--reserved/);
|
||||||
|
await expect(npcTurns).toHaveText('NPC 장수');
|
||||||
|
await expect(npcTurns).not.toHaveClass(/turns--reserved/);
|
||||||
|
|
||||||
|
const turnFontSizes = await Promise.all([
|
||||||
|
reservedTurns.evaluate((element) => getComputedStyle(element).fontSize),
|
||||||
|
npcTurns.evaluate((element) => getComputedStyle(element).fontSize),
|
||||||
|
]);
|
||||||
|
expect(Number.parseFloat(turnFontSizes[0])).toBeLessThan(Number.parseFloat(pageStyle.fontSize));
|
||||||
|
expect(turnFontSizes[1]).toBe(pageStyle.fontSize);
|
||||||
|
|
||||||
|
const reservedLineTops = await reservedTurns
|
||||||
|
.locator('.turn-line')
|
||||||
|
.evaluateAll((elements) => elements.map((element) => Math.round(element.getBoundingClientRect().top)));
|
||||||
|
expect(new Set(reservedLineTops).size).toBe(2);
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
resolve(artifactRoot, `core-current-city-dense-${viewport.name}.json`),
|
||||||
|
`${JSON.stringify({ pageStyle, nameLineCount, turnFontSizes, reservedLineTops }, null, 2)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, `core-current-city-dense-${viewport.name}.png`),
|
||||||
|
fullPage: true,
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('current-city renders a missing merchant rate with the legacy dash and percent text', async ({ page }) => {
|
test('current-city renders a missing merchant rate with the legacy dash and percent text', async ({ page }) => {
|
||||||
await install(page, 'member', null);
|
await install(page, 'member', null);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
|||||||
@@ -656,17 +656,32 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명
|
|||||||
await expect(page.locator('.main-page')).not.toContainText('che_');
|
await expect(page.locator('.main-page')).not.toContainText('che_');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('메인 개인 기록의 전투 결과는 월 표제와 시각을 한 줄에 표시한다', async ({ page }) => {
|
test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을 유지한다', async ({ page }) => {
|
||||||
const state: FixtureState = {
|
const state: FixtureState = {
|
||||||
permission: 'head',
|
permission: 'head',
|
||||||
myset: 3,
|
myset: 3,
|
||||||
settingMutations: [],
|
settingMutations: [],
|
||||||
accessPages: [],
|
accessPages: [],
|
||||||
recentRecords: {
|
recentRecords: {
|
||||||
global: [],
|
global: [
|
||||||
general: [
|
{
|
||||||
|
id: 18611,
|
||||||
|
text:
|
||||||
|
'<C>●</>9월:<D><b>위</b></>의 <Y>Administrator</>가 <G><b>낙양</b></>으로 ' +
|
||||||
|
'진격합니다.<span class="hidden_but_copyable">(전투시드: 0123456789abcdef)</span>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 18610,
|
||||||
|
text: '<C>●</>9월:<Y>Administrator</>가 <D><b>위</b></>에 <S>임관</>했습니다.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 18609,
|
id: 18609,
|
||||||
|
text: '<C>●</>9월:<Y>뇌동</>의 기병이 퇴각했습니다.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
general: [
|
||||||
|
{
|
||||||
|
id: 18608,
|
||||||
text:
|
text:
|
||||||
'<S>◆</>186년 9월:<div class="small_war_log">' +
|
'<S>◆</>186년 9월:<div class="small_war_log">' +
|
||||||
'<span class="me"><span class="crew_type">귀병</span> ' +
|
'<span class="me"><span class="crew_type">귀병</span> ' +
|
||||||
@@ -686,6 +701,31 @@ test('메인 개인 기록의 전투 결과는 월 표제와 시각을 한 줄
|
|||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
await page.goto('');
|
await page.goto('');
|
||||||
|
|
||||||
|
const inspectGlobalRhythm = async (selector: string) => {
|
||||||
|
const lines = page.locator(selector);
|
||||||
|
await expect(lines).toHaveCount(3);
|
||||||
|
return lines.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
top: rect.top,
|
||||||
|
height: rect.height,
|
||||||
|
lineHeight: getComputedStyle(element).lineHeight,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const assertUniformGlobalRhythm = (geometry: Awaited<ReturnType<typeof inspectGlobalRhythm>>) => {
|
||||||
|
expect(geometry.map((line) => line.height)).toEqual([21, 21, 21]);
|
||||||
|
expect(geometry.map((line) => line.lineHeight)).toEqual(['21px', '21px', '21px']);
|
||||||
|
expect(geometry[1]!.top - geometry[0]!.top).toBe(21);
|
||||||
|
expect(geometry[2]!.top - geometry[1]!.top).toBe(21);
|
||||||
|
};
|
||||||
|
|
||||||
|
const desktopGlobalGeometry = await inspectGlobalRhythm('.record-zone [data-record-bucket="global"] .record-line');
|
||||||
|
assertUniformGlobalRhythm(desktopGlobalGeometry);
|
||||||
|
await persistParityArtifact(page, 'core-main-trend-log-rhythm-desktop', desktopGlobalGeometry);
|
||||||
|
|
||||||
const expectedText = '◆186년 9월:귀병 【Administrator】 0(-2209) ← 1361(-5539) 기병 【ⓝ뇌동】 12:54';
|
const expectedText = '◆186년 9월:귀병 【Administrator】 0(-2209) ← 1361(-5539) 기병 【ⓝ뇌동】 12:54';
|
||||||
const inspect = async (line: Locator) => {
|
const inspect = async (line: Locator) => {
|
||||||
await expect(line).toContainText(expectedText);
|
await expect(line).toContainText(expectedText);
|
||||||
@@ -723,6 +763,11 @@ test('메인 개인 기록의 전투 결과는 월 표제와 시각을 한 줄
|
|||||||
page.locator('.record-zone-mobile [data-record-bucket="general"] .record-line').first()
|
page.locator('.record-zone-mobile [data-record-bucket="general"] .record-line').first()
|
||||||
);
|
);
|
||||||
assertSingleLine(mobileGeometry);
|
assertSingleLine(mobileGeometry);
|
||||||
|
const mobileGlobalGeometry = await inspectGlobalRhythm(
|
||||||
|
'.record-zone-mobile [data-record-bucket="global"] .record-line'
|
||||||
|
);
|
||||||
|
assertUniformGlobalRhythm(mobileGlobalGeometry);
|
||||||
|
await persistParityArtifact(page, 'core-main-trend-log-rhythm-mobile', mobileGlobalGeometry);
|
||||||
await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry);
|
await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -179,26 +179,54 @@ test('prioritizes core general fields and keeps context and inheritance progress
|
|||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const style = getComputedStyle(element);
|
const style = getComputedStyle(element);
|
||||||
return {
|
return {
|
||||||
|
top: rect.top,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
backgroundColor: style.backgroundColor,
|
backgroundColor: style.backgroundColor,
|
||||||
color: style.color,
|
color: style.color,
|
||||||
|
borderTopWidth: style.borderTopWidth,
|
||||||
|
borderRightWidth: style.borderRightWidth,
|
||||||
|
borderBottomWidth: style.borderBottomWidth,
|
||||||
|
borderBottomColor: style.borderBottomColor,
|
||||||
|
marginTop: style.marginTop,
|
||||||
fontSize: style.fontSize,
|
fontSize: style.fontSize,
|
||||||
fontWeight: style.fontWeight,
|
fontWeight: style.fontWeight,
|
||||||
cursor: style.cursor,
|
cursor: style.cursor,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(defaultButtonStyle).toEqual({
|
expect(defaultButtonStyle).toMatchObject({
|
||||||
height: 40,
|
height: 40,
|
||||||
backgroundColor: 'rgb(0, 88, 44)',
|
backgroundColor: 'rgb(0, 88, 44)',
|
||||||
color: 'rgb(255, 255, 255)',
|
color: 'rgb(255, 255, 255)',
|
||||||
|
borderTopWidth: '0px',
|
||||||
|
borderRightWidth: '1px',
|
||||||
|
borderBottomWidth: '4px',
|
||||||
|
borderBottomColor: 'rgb(0, 79, 40)',
|
||||||
|
marginTop: '0px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
});
|
});
|
||||||
await randomButton.hover();
|
await randomButton.hover();
|
||||||
await expect
|
const hoverButtonStyle = await randomButton.evaluate((element) => {
|
||||||
.poll(() => randomButton.evaluate((element) => getComputedStyle(element).backgroundColor))
|
const rect = element.getBoundingClientRect();
|
||||||
.toBe('rgb(0, 109, 55)');
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
top: rect.top,
|
||||||
|
height: rect.height,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
borderBottomWidth: style.borderBottomWidth,
|
||||||
|
borderBottomColor: style.borderBottomColor,
|
||||||
|
marginTop: style.marginTop,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(hoverButtonStyle).toMatchObject({
|
||||||
|
top: defaultButtonStyle.top + 1,
|
||||||
|
height: 39,
|
||||||
|
backgroundColor: 'rgb(0, 88, 44)',
|
||||||
|
borderBottomWidth: '3px',
|
||||||
|
borderBottomColor: 'rgb(0, 79, 40)',
|
||||||
|
marginTop: '1px',
|
||||||
|
});
|
||||||
await page.screenshot({ path: testInfo.outputPath('join-stat-actions-hover-desktop.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('join-stat-actions-hover-desktop.png'), fullPage: true });
|
||||||
await randomButton.focus();
|
await randomButton.focus();
|
||||||
await expect(randomButton).toBeFocused();
|
await expect(randomButton).toBeFocused();
|
||||||
@@ -210,9 +238,26 @@ test('prioritizes core general fields and keeps context and inheritance progress
|
|||||||
randomButtonBox!.y + randomButtonBox!.height / 2
|
randomButtonBox!.y + randomButtonBox!.height / 2
|
||||||
);
|
);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await expect
|
const activeButtonStyle = await randomButton.evaluate((element) => {
|
||||||
.poll(() => randomButton.evaluate((element) => getComputedStyle(element).backgroundColor))
|
const rect = element.getBoundingClientRect();
|
||||||
.toBe('rgb(0, 69, 35)');
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
top: rect.top,
|
||||||
|
height: rect.height,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
borderBottomWidth: style.borderBottomWidth,
|
||||||
|
borderBottomColor: style.borderBottomColor,
|
||||||
|
marginTop: style.marginTop,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(activeButtonStyle).toMatchObject({
|
||||||
|
top: defaultButtonStyle.top + 2,
|
||||||
|
height: 38,
|
||||||
|
backgroundColor: 'rgb(0, 88, 44)',
|
||||||
|
borderBottomWidth: '2px',
|
||||||
|
borderBottomColor: 'rgb(0, 79, 40)',
|
||||||
|
marginTop: '2px',
|
||||||
|
});
|
||||||
await page.screenshot({ path: testInfo.outputPath('join-stat-actions-active-desktop.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('join-stat-actions-active-desktop.png'), fullPage: true });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
const setRandomValues = async (values: number[]) => {
|
const setRandomValues = async (values: number[]) => {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type NavigationFixture = {
|
|||||||
forceSnapshotCalls?: number;
|
forceSnapshotCalls?: number;
|
||||||
refreshDelayMs?: number;
|
refreshDelayMs?: number;
|
||||||
largeCommandTable?: boolean;
|
largeCommandTable?: boolean;
|
||||||
|
refCommandCategories?: boolean;
|
||||||
currentYear?: number;
|
currentYear?: number;
|
||||||
currentMonth?: number;
|
currentMonth?: number;
|
||||||
scenarioTitle?: string;
|
scenarioTitle?: string;
|
||||||
@@ -110,32 +111,48 @@ const emitReadModelInvalidation = (page: Page, invalidation: ReturnType<typeof r
|
|||||||
);
|
);
|
||||||
}, invalidation);
|
}, invalidation);
|
||||||
|
|
||||||
const commandTableFixture = (large: boolean, blockedCount = 0) => ({
|
const refCommandCategoryFixture = ['개인', '내정', '군사', '인사', '계략', '국가'].map((category, index) => ({
|
||||||
general: large
|
category,
|
||||||
? ['내정', '군사', '계략'].map((category, categoryIndex) => ({
|
values: [
|
||||||
category,
|
{
|
||||||
values: Array.from({ length: 16 }, (_, localIndex) => {
|
key: `ref-command-${index}`,
|
||||||
const index = categoryIndex * 16 + localIndex;
|
name: category === '계략' ? '화계' : `${category} 명령`,
|
||||||
return {
|
reqArg: false,
|
||||||
key: `command-${index}`,
|
possible: true,
|
||||||
name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`,
|
status: 'available' as const,
|
||||||
reqArg: index % 2 === 0,
|
inputFields: [],
|
||||||
possible: index >= blockedCount,
|
},
|
||||||
status: index >= blockedCount ? 'available' : 'blocked',
|
],
|
||||||
inputFields: [
|
}));
|
||||||
{
|
|
||||||
key: 'amount',
|
const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false) => ({
|
||||||
label: '수량',
|
general: refCategories
|
||||||
kind: 'number',
|
? refCommandCategoryFixture
|
||||||
required: true,
|
: large
|
||||||
min: 1,
|
? ['내정', '군사', '계략'].map((category, categoryIndex) => ({
|
||||||
max: 10_000,
|
category,
|
||||||
},
|
values: Array.from({ length: 16 }, (_, localIndex) => {
|
||||||
],
|
const index = categoryIndex * 16 + localIndex;
|
||||||
};
|
return {
|
||||||
}),
|
key: `command-${index}`,
|
||||||
}))
|
name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`,
|
||||||
: [],
|
reqArg: index % 2 === 0,
|
||||||
|
possible: index >= blockedCount,
|
||||||
|
status: index >= blockedCount ? 'available' : 'blocked',
|
||||||
|
inputFields: [
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
label: '수량',
|
||||||
|
kind: 'number',
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 10_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
nation: [],
|
nation: [],
|
||||||
inputOptions: {
|
inputOptions: {
|
||||||
cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })),
|
cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })),
|
||||||
@@ -375,7 +392,11 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
? {
|
? {
|
||||||
kind: 'snapshot' as const,
|
kind: 'snapshot' as const,
|
||||||
revision: currentCommandTableRevision,
|
revision: currentCommandTableRevision,
|
||||||
data: commandTableFixture(state.largeCommandTable === true, state.commandBlockedCount),
|
data: commandTableFixture(
|
||||||
|
state.largeCommandTable === true,
|
||||||
|
state.commandBlockedCount,
|
||||||
|
state.refCommandCategories === true
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: input.known.commandTable === currentCommandTableRevision
|
: input.known.commandTable === currentCommandTableRevision
|
||||||
? { kind: 'unchanged' as const, revision: currentCommandTableRevision }
|
? { kind: 'unchanged' as const, revision: currentCommandTableRevision }
|
||||||
@@ -972,6 +993,57 @@ test('pure NPC message senders are not rendered as reply targets', async ({ page
|
|||||||
await persistArtifact(page, `${basePath.slice(1)}-npc-reply-targets-desktop-1200`);
|
await persistArtifact(page, `${basePath.slice(1)}-npc-reply-targets-desktop-1200`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('main reserved-turn picker renders the Ref general category order', async ({ page }) => {
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 1,
|
||||||
|
permission: 0,
|
||||||
|
nationLevel: 1,
|
||||||
|
stage: 0,
|
||||||
|
npcMode: 1,
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
refCommandCategories: true,
|
||||||
|
reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })),
|
||||||
|
};
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await waitForMain(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
|
const picker = page.getByTestId('command-picker');
|
||||||
|
const categoryButtons = picker.locator('.category-btn');
|
||||||
|
await expect(categoryButtons).toHaveText(['개인', '내정', '군사', '인사', '계략', '국가']);
|
||||||
|
|
||||||
|
const desktopGeometry = await picker.evaluate((element) => {
|
||||||
|
const categories = element.querySelector<HTMLElement>('.category-list');
|
||||||
|
if (!categories) throw new Error('command category list is missing');
|
||||||
|
const buttons = [...categories.querySelectorAll<HTMLElement>('.category-btn')];
|
||||||
|
return {
|
||||||
|
columns: getComputedStyle(categories).gridTemplateColumns,
|
||||||
|
rows: new Set(buttons.map((button) => button.getBoundingClientRect().y)).size,
|
||||||
|
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(desktopGeometry.columns.split(' ')).toHaveLength(3);
|
||||||
|
expect(desktopGeometry.rows).toBe(2);
|
||||||
|
expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||||||
|
|
||||||
|
const strategyCategory = picker.getByRole('button', { name: '계략', exact: true });
|
||||||
|
await strategyCategory.hover();
|
||||||
|
await strategyCategory.focus();
|
||||||
|
await expect(strategyCategory).toBeFocused();
|
||||||
|
await strategyCategory.click();
|
||||||
|
await expect(strategyCategory).toHaveClass(/active/);
|
||||||
|
await expect(picker.locator('.command-item')).toHaveText(['화계']);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
|
const mobilePicker = page.getByTestId('command-picker');
|
||||||
|
await expect(mobilePicker.locator('.category-btn')).toHaveText(['개인', '내정', '군사', '인사', '계략', '국가']);
|
||||||
|
expect(await mobilePicker.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
|
||||||
|
await persistArtifact(page, `${basePath.slice(1)}-main-reserved-ref-categories-mobile-500`);
|
||||||
|
});
|
||||||
|
|
||||||
test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => {
|
test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => {
|
||||||
const state: NavigationFixture = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 1,
|
officerLevel: 1,
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
|
|||||||
<td>{{ general.crew ?? '?' }}</td>
|
<td>{{ general.crew ?? '?' }}</td>
|
||||||
<td>{{ general.train ?? '?' }}</td>
|
<td>{{ general.train ?? '?' }}</td>
|
||||||
<td>{{ general.atmos ?? '?' }}</td>
|
<td>{{ general.atmos ?? '?' }}</td>
|
||||||
<td class="turns">
|
<td class="turns" :class="{ 'turns--reserved': general.turns.length > 0 }">
|
||||||
<template v-if="general.turns.length">
|
<template v-if="general.turns.length">
|
||||||
<span v-for="(turn, index) in general.turns" :key="index" class="turn-line"
|
<span v-for="(turn, index) in general.turns" :key="index" class="turn-line"
|
||||||
>{{ index + 1 }} : {{ turn }}</span
|
>{{ index + 1 }} : {{ turn }}</span
|
||||||
@@ -321,8 +321,8 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="legacy-banner">
|
<td class="legacy-banner">
|
||||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
|
||||||
HideD(hided62@gmail.com) /
|
/
|
||||||
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
|
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -335,9 +335,9 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
|
|||||||
.city-page {
|
.city-page {
|
||||||
width: 1000px;
|
width: 1000px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
font-family: 'Times New Roman', serif;
|
font-family: var(--sammo-font-sans);
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
line-height: normal;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
.legacy-table {
|
.legacy-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -419,6 +419,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
|
|||||||
}
|
}
|
||||||
.general-names {
|
.general-names {
|
||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
|
white-space: normal !important;
|
||||||
}
|
}
|
||||||
.unknown {
|
.unknown {
|
||||||
color: gray;
|
color: gray;
|
||||||
@@ -447,7 +448,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
|
|||||||
height: 64px;
|
height: 64px;
|
||||||
object-fit: fill;
|
object-fit: fill;
|
||||||
}
|
}
|
||||||
.turns {
|
.turns--reserved {
|
||||||
font-size: x-small;
|
font-size: x-small;
|
||||||
}
|
}
|
||||||
.turn-line {
|
.turn-line {
|
||||||
|
|||||||
@@ -726,10 +726,26 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-actions" role="group" aria-label="능력치 빠른 설정">
|
<div class="stat-actions" role="group" aria-label="능력치 빠른 설정">
|
||||||
<button type="button" @click="applyRandomStats">랜덤형</button>
|
<button class="legacy-button legacy-button--navigation" type="button" @click="applyRandomStats">
|
||||||
<button type="button" @click="applyLeadpowStats">통솔무력형</button>
|
랜덤형
|
||||||
<button type="button" @click="applyLeadintStats">통솔지력형</button>
|
</button>
|
||||||
<button type="button" @click="applyPowintStats">무력지력형</button>
|
<button
|
||||||
|
class="legacy-button legacy-button--navigation"
|
||||||
|
type="button"
|
||||||
|
@click="applyLeadpowStats"
|
||||||
|
>
|
||||||
|
통솔무력형
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="legacy-button legacy-button--navigation"
|
||||||
|
type="button"
|
||||||
|
@click="applyLeadintStats"
|
||||||
|
>
|
||||||
|
통솔지력형
|
||||||
|
</button>
|
||||||
|
<button class="legacy-button legacy-button--navigation" type="button" @click="applyPowintStats">
|
||||||
|
무력지력형
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="accountIcons.length" class="icon-choice">
|
<div v-if="accountIcons.length" class="icon-choice">
|
||||||
@@ -1385,26 +1401,21 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.stat-actions button {
|
.stat-actions button {
|
||||||
flex: 1 1 140px;
|
flex: 1 1 140px;
|
||||||
|
height: 40px;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
border: 1px solid #004f28;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: #00582c;
|
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
color: #fff;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-actions button:hover {
|
.stat-actions button:not(:disabled):hover {
|
||||||
border-color: #005f30;
|
height: 39px;
|
||||||
background: #006d37;
|
min-height: 39px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-actions button:active {
|
.stat-actions button:not(:disabled):active {
|
||||||
border-color: #003d1f;
|
height: 38px;
|
||||||
background: #004523;
|
min-height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-summary {
|
.stat-summary {
|
||||||
|
|||||||
@@ -670,11 +670,21 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.record-line {
|
.record-line {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 21px;
|
||||||
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-wrap: normal;
|
overflow-wrap: normal;
|
||||||
|
line-height: 21px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.record-line :deep(.small_war_log) {
|
||||||
|
height: 21px;
|
||||||
|
line-height: 21px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
.record-line :deep(.hidden_but_copyable) {
|
.record-line :deep(.hidden_but_copyable) {
|
||||||
color: transparent !important;
|
color: transparent !important;
|
||||||
font-size: 0;
|
font-size: 0;
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ storage, route guards, and image loading.
|
|||||||
| gateway Kakao OTP | `index.php#modalOTP` | 동일 문구·500px modal, desktop/mobile geometry와 색상·typography, password/OAuth 진입, autofocus·focus-visible·active·disabled·오류 재시도·session 저장 |
|
| gateway Kakao OTP | `index.php#modalOTP` | 동일 문구·500px modal, desktop/mobile geometry와 색상·typography, password/OAuth 진입, autofocus·focus-visible·active·disabled·오류 재시도·session 저장 |
|
||||||
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
||||||
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
||||||
| current city | `hwe/b_currentCity.php` | ref-specific 16px Times New Roman, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
|
| current city | `hwe/b_currentCity.php` | main-page Pretendard 14px, wrapping general-name summary, small reserved-turn lines but normal-size NPC labels, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
|
||||||
| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error |
|
| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error |
|
||||||
| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error |
|
| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error |
|
||||||
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
||||||
@@ -167,6 +167,14 @@ and is never written to the artifact. The matching core fixture is
|
|||||||
`app/game-frontend/e2e/inGameInfo.spec.ts`, which writes its computed DOM and
|
`app/game-frontend/e2e/inGameInfo.spec.ts`, which writes its computed DOM and
|
||||||
screenshot only when `CITY_PARITY_ARTIFACT_DIR` is set.
|
screenshot only when `CITY_PARITY_ARTIFACT_DIR` is set.
|
||||||
|
|
||||||
|
Ref's standalone current-city document inherits the browser's 16px Times face
|
||||||
|
because its page-local includes do not load the in-game Pretendard baseline.
|
||||||
|
Core intentionally follows the requested main-page 14px Pretendard typography
|
||||||
|
instead. The Ref row contract still applies to commands: only a non-NPC
|
||||||
|
general's reserved turns use `general_turn_text`/`x-small`; `NPC 장수`, foreign
|
||||||
|
nation, and wanderer labels stay at the table's normal font size. The summary
|
||||||
|
general-name cell must remain wrappable when many generals share a city.
|
||||||
|
|
||||||
징병·모병의 Ref 화면은 다음 collector로 1000/500px DOM, 이미지 natural size,
|
징병·모병의 Ref 화면은 다음 collector로 1000/500px DOM, 이미지 natural size,
|
||||||
불가능 병종 toggle과 hover/focus를 수집합니다. 기본 모드는 현재 Ref session을
|
불가능 병종 toggle과 hover/focus를 수집합니다. 기본 모드는 현재 Ref session을
|
||||||
사용합니다. 비교 계정이 없는 환경에서는 `REF_STATIC_FIXTURE=1`로 Ref가 빌드한
|
사용합니다. 비교 계정이 없는 환경에서는 `REF_STATIC_FIXTURE=1`로 Ref가 빌드한
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ export class ActionDefinition<
|
|||||||
{
|
{
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.SUMMARY,
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.PLAIN,
|
format: LogFormat.MONTH,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export class ActionDefinition<
|
|||||||
context.addLog(`<Y>${context.general.name}</>${josaYi} <D><b>${destNationName}</b></>에 <S>임관</>했습니다.`, {
|
context.addLog(`<Y>${context.general.name}</>${josaYi} <D><b>${destNationName}</b></>에 <S>임관</>했습니다.`, {
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.SUMMARY,
|
category: LogCategory.SUMMARY,
|
||||||
format: LogFormat.RAWTEXT,
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
tryApplyUniqueLottery(context, {
|
tryApplyUniqueLottery(context, {
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export class ActionDefinition<
|
|||||||
context.addLog(`<Y>${general.name}</>${josaYi} <D><b>${destNation.name}</b></>에 <S>임관</>했습니다.`, {
|
context.addLog(`<Y>${general.name}</>${josaYi} <D><b>${destNation.name}</b></>에 <S>임관</>했습니다.`, {
|
||||||
scope: LogScope.SYSTEM,
|
scope: LogScope.SYSTEM,
|
||||||
category: LogCategory.SUMMARY,
|
category: LogCategory.SUMMARY,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialNationGenLimit = context.initialNationGenLimit ?? 10;
|
const initialNationGenLimit = context.initialNationGenLimit ?? 10;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { General, Nation } from '../../../src/domain/entities.js';
|
||||||
|
import type { GeneralActionResolveContext } from '../../../src/actions/engine.js';
|
||||||
|
import { finalizeLogEntry } from '../../../src/logging/entries.js';
|
||||||
|
import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '../../../src/logging/types.js';
|
||||||
|
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
|
||||||
|
import { ActionDefinition as AppointmentAction } from '../../../src/actions/turn/general/che_임관.js';
|
||||||
|
import { ActionDefinition as RandomAppointmentAction } from '../../../src/actions/turn/general/che_랜덤임관.js';
|
||||||
|
import { ActionDefinition as FollowAppointmentAction } from '../../../src/actions/turn/general/che_장수대상임관.js';
|
||||||
|
|
||||||
|
const actor = {
|
||||||
|
id: 1,
|
||||||
|
name: '검증장수',
|
||||||
|
nationId: 0,
|
||||||
|
cityId: 1,
|
||||||
|
npcState: 0,
|
||||||
|
officerLevel: 1,
|
||||||
|
experience: 100,
|
||||||
|
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||||
|
role: {
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
},
|
||||||
|
meta: {},
|
||||||
|
} as General;
|
||||||
|
|
||||||
|
const nation = {
|
||||||
|
id: 2,
|
||||||
|
name: '검증국',
|
||||||
|
capitalCityId: 2,
|
||||||
|
meta: { gennum: 1 },
|
||||||
|
} as unknown as Nation;
|
||||||
|
|
||||||
|
const createLogSink =
|
||||||
|
(logs: LogEntryDraft[]): GeneralActionResolveContext['addLog'] =>
|
||||||
|
(text, options = {}) => {
|
||||||
|
logs.push({
|
||||||
|
scope: options.scope ?? LogScope.GENERAL,
|
||||||
|
category: options.category ?? LogCategory.ACTION,
|
||||||
|
text,
|
||||||
|
format: options.format ?? LogFormat.MONTH,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectMonthlySummary = (logs: LogEntryDraft[]): void => {
|
||||||
|
const summary = logs.find((entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY);
|
||||||
|
expect(summary).toBeDefined();
|
||||||
|
expect(summary?.format).toBe(LogFormat.MONTH);
|
||||||
|
expect(finalizeLogEntry(summary!, { year: 186, month: 9 })?.text).toMatch(/^<C>●<\/>9월:/u);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('appointment global summary log format', () => {
|
||||||
|
it('adds the legacy month prefix to direct appointment', () => {
|
||||||
|
const logs: LogEntryDraft[] = [];
|
||||||
|
const action = new AppointmentAction({} as TurnCommandEnv);
|
||||||
|
|
||||||
|
action.resolve(
|
||||||
|
{
|
||||||
|
general: actor,
|
||||||
|
destNation: nation,
|
||||||
|
destNationGeneralCount: 1,
|
||||||
|
destCityId: 2,
|
||||||
|
addLog: createLogSink(logs),
|
||||||
|
} as unknown as Parameters<typeof action.resolve>[0],
|
||||||
|
{ destNationId: nation.id }
|
||||||
|
);
|
||||||
|
|
||||||
|
expectMonthlySummary(logs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the legacy month prefix to random appointment', () => {
|
||||||
|
const action = new RandomAppointmentAction({} as TurnCommandEnv);
|
||||||
|
const result = action.resolve(
|
||||||
|
{
|
||||||
|
general: actor,
|
||||||
|
rng: {
|
||||||
|
nextFloat1: () => 0,
|
||||||
|
nextInt: () => 0,
|
||||||
|
},
|
||||||
|
candidateNations: [
|
||||||
|
{
|
||||||
|
nation,
|
||||||
|
generals: [],
|
||||||
|
generalCount: 1,
|
||||||
|
monarchCityId: 2,
|
||||||
|
monarchAffinity: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relYear: 1,
|
||||||
|
initialNationGenLimit: 10,
|
||||||
|
historicalNpcAffinityMode: false,
|
||||||
|
} as unknown as Parameters<typeof action.resolve>[0],
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const logs = result.effects.flatMap((effect) => (effect.type === 'log' ? [effect.entry] : []));
|
||||||
|
|
||||||
|
expectMonthlySummary(logs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the same month prefix when following another general', () => {
|
||||||
|
const logs: LogEntryDraft[] = [];
|
||||||
|
const action = new FollowAppointmentAction();
|
||||||
|
|
||||||
|
action.resolve(
|
||||||
|
{
|
||||||
|
general: actor,
|
||||||
|
destNation: nation,
|
||||||
|
destNationGeneralCount: 1,
|
||||||
|
initialNationGenLimit: 10,
|
||||||
|
addLog: createLogSink(logs),
|
||||||
|
} as unknown as Parameters<typeof action.resolve>[0],
|
||||||
|
{ destGeneralID: 9 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expectMonthlySummary(logs);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user