merge: 최신 main을 기본 등용 명령 복구에 통합

This commit is contained in:
2026-08-21 01:33:14 +00:00
49 changed files with 1923 additions and 332 deletions
+33 -7
View File
@@ -6,10 +6,7 @@ import {
} from '@sammo-ts/logic';
import { describe, expect, it } from 'vitest';
import {
buildTurnCommandInputFields,
parseReservedTurnArgs,
} from '../src/turns/commandInput.js';
import { buildTurnCommandInputFields, parseReservedTurnArgs } from '../src/turns/commandInput.js';
describe('turn command argument input', () => {
it('builds supported fields for every argument-bearing command module', async () => {
@@ -22,6 +19,9 @@ describe('turn command argument input', () => {
key: spec.key,
fields: buildTurnCommandInputFields(spec),
}));
const nationFields = nation
.filter((spec) => spec.reqArg)
.map((spec) => ({ key: spec.key, fields: buildTurnCommandInputFields(spec) }));
expect(argumentSpecs).toHaveLength(44);
expect(fields.every((entry) => entry.fields.length > 0)).toBe(true);
@@ -32,6 +32,34 @@ describe('turn command argument input', () => {
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
{ key: 'amountList', kind: 'numberTuple' },
]);
expect(
nationFields
.filter((entry) => entry.key !== 'che_발령')
.flatMap((entry) =>
entry.fields
.filter((field) => field.optionSource === 'cities' || field.optionSource === 'nations')
.map((field) => `${entry.key}:${field.optionSource}`)
)
.sort()
).toEqual(
[
'che_급습:nations',
'che_물자원조:nations',
'che_백성동원:cities',
'che_불가침제의:nations',
'che_불가침파기제의:nations',
'che_선전포고:nations',
'che_수몰:cities',
'che_이호경식:nations',
'che_종전제의:nations',
'che_천도:cities',
'che_초토화:cities',
'che_피장파장:nations',
'che_허보:cities',
'cr_인구이동:cities',
].sort()
);
});
it('normalizes valid arguments and rejects malformed or wrong-scope commands', async () => {
@@ -50,8 +78,6 @@ describe('turn command argument input', () => {
amount: 200,
destGeneralId: 7,
});
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow(
'Unknown general turn command'
);
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
});
});
+110
View File
@@ -260,6 +260,116 @@ describe('messages router missing-flow compatibility', () => {
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy']));
});
it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => {
const ruler = { ...general, officerLevel: 12 } as GeneralRow;
const queryRaw = vi.fn(async () => [{ id: 53 }]);
const changeJournal = new ChangeJournal();
const { caller } = buildContext(
{
$queryRaw: queryRaw,
general: {
findUnique: vi.fn(async () => ruler),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#112233', meta: {} })),
},
},
{ changeJournal }
);
const result = await caller.messages.send({
generalId: ruler.id,
mailbox: 9000,
text: '우리 나라로 와주세요',
});
expect(result.msgType).toBe('diplomacy');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy']));
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 9000 },
{ domain: 'messages.mailbox', entityId: 9001 },
]);
});
it('keeps the wanderer mailbox unavailable to a non-diplomat on the server', async () => {
const queryRaw = vi.fn(async () => [{ id: 54 }]);
const { caller } = buildContext({
$queryRaw: queryRaw,
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#112233', meta: {} })),
},
});
const result = await caller.messages.send({
generalId: general.id,
mailbox: 9000,
text: '권한 없는 재야 광고',
});
expect(result.msgType).toBe('national');
expect(queryRaw).toHaveBeenCalledTimes(1);
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
});
it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => {
const wanderer = { ...general, nationId: 0, officerLevel: 0 } as GeneralRow;
const advertisementRow = {
id: 55,
mailbox: 9000,
type: 'diplomacy',
src: 9001,
dest: 9000,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: 1,
generalName: '위왕',
nationId: 1,
nationName: '위',
color: '#112233',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 0,
nationName: '재야',
color: '#000000',
icon: '',
},
text: '우리 나라로 와주세요',
option: {},
},
};
const queryRaw = vi.fn(async (...args: unknown[]) => {
const values = args.slice(1);
return values.includes(9000) && values.includes('diplomacy') ? [advertisementRow] : [];
});
const { caller } = buildContext({
$queryRaw: queryRaw,
general: {
findUnique: vi.fn(async () => wanderer),
findMany: vi.fn(async () => []),
},
});
const result = await caller.messages.getRecent({ generalId: wanderer.id });
expect(result.permission).toBe(-1);
expect(result.diplomacy).toEqual([
expect.objectContaining({
text: '우리 나라로 와주세요',
dest: expect.objectContaining({ nationId: 0, nationName: '재야' }),
option: {},
}),
]);
});
it('blocks private messages between foreign ambassadors', async () => {
const ambassador = {
...general,
@@ -5,6 +5,16 @@ import { describe, expect, it } from 'vitest';
import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js';
type LoadedScenario = Awaited<ReturnType<typeof loadScenarioDefinitionById>>;
const readItemSlot = (scenario: LoadedScenario, slot: string): Record<string, number> => {
const allItems = scenario.config.const.allItems as Record<string, Record<string, number>> | undefined;
return allItems?.[slot] ?? {};
};
const readAvailableSpecialWar = (scenario: LoadedScenario): string[] =>
(scenario.config.const.availableSpecialWar as string[] | undefined) ?? [];
describe('tracked scenario resources', () => {
it('loads every scenario through its composed resource graph', async () => {
const scenarioRoot = path.dirname(resolveScenarioDefaultsPath());
@@ -35,4 +45,36 @@ describe('tracked scenario resources', () => {
]);
}
});
it('keeps the buyable war-special pack scoped to each Ref scenario contract', async () => {
const [ordinaryBlank, legacySecretBlank, mirrorBlank, multiUnitBlank, moreEffectBlank, composedAddon] =
await Promise.all(
[0, 902, 910, 912, 913, 2141].map((scenarioId) => loadScenarioDefinitionById(scenarioId))
);
expect(
Object.keys(readItemSlot(ordinaryBlank, 'item')).filter((key) => key.startsWith('event_전투특기_'))
).toEqual([]);
const legacySecretItems = readItemSlot(legacySecretBlank, 'item');
expect(Object.keys(legacySecretItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(19);
expect(legacySecretItems).not.toHaveProperty('event_전투특기_견고');
expect(readAvailableSpecialWar(legacySecretBlank)).not.toContain('che_견고');
const mirrorItems = readItemSlot(mirrorBlank, 'item');
expect(Object.keys(mirrorItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(19);
expect(mirrorItems).not.toHaveProperty('event_전투특기_척사');
expect(readAvailableSpecialWar(mirrorBlank)).not.toContain('che_척사');
const multiUnitItems = readItemSlot(multiUnitBlank, 'item');
expect(Object.keys(multiUnitItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(19);
expect(multiUnitItems).not.toHaveProperty('event_전투특기_견고');
const moreEffectItems = readItemSlot(moreEffectBlank, 'item');
const composedAddonItems = readItemSlot(composedAddon, 'item');
expect(Object.keys(moreEffectItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20);
expect(Object.keys(composedAddonItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20);
expect(readItemSlot(moreEffectBlank, 'horse').che_명마_07_백마).toBe(4);
expect(readItemSlot(composedAddon, 'horse').che_명마_07_백마).toBe(2);
});
});
@@ -93,6 +93,49 @@ const canRun = await canConnectToDatabase(databaseUrl);
const describeDb = describe.runIf(canRun);
describeDb('scenario database seed', () => {
test('persists each blank-land scenario item contract without leaking the shared addon', async () => {
const readPersistedItemContract = async (targetScenarioId: number) => {
const { applied } = await seedScenarioToDatabase({
scenarioId: targetScenarioId,
databaseUrl,
});
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
try {
const worldState = await connector.prisma.worldState.findFirstOrThrow();
const config = worldState.config as Record<string, unknown>;
const scenarioConst = (config.const ?? {}) as Record<string, unknown>;
const allItems = (scenarioConst.allItems ?? {}) as Record<string, Record<string, number>>;
const items = allItems.item ?? {};
const availableSpecialWar = (scenarioConst.availableSpecialWar ?? []) as string[];
return {
applied,
battleTraitItemCount: Object.keys(items).filter((key) => key.startsWith('event_전투특기_')).length,
availableSpecialWar,
items,
};
} finally {
await connector.disconnect();
}
};
const ordinaryBlank = await readPersistedItemContract(0);
const legacySecretBlank = await readPersistedItemContract(902);
expect(ordinaryBlank).toMatchObject({
applied: true,
battleTraitItemCount: 0,
availableSpecialWar: [],
});
expect(legacySecretBlank.applied).toBe(true);
expect(legacySecretBlank.battleTraitItemCount).toBe(19);
expect(legacySecretBlank.availableSpecialWar).toHaveLength(19);
expect(legacySecretBlank.items).not.toHaveProperty('event_전투특기_견고');
expect(legacySecretBlank.availableSpecialWar).not.toContain('che_견고');
});
test('snapshots the complete opening inheritance balance before game activity', async () => {
const serverId = 'scenario-seeder-inheritance-baseline';
const userId = 'scenario-seeder-inheritance-user';
+127 -25
View File
@@ -522,6 +522,21 @@ const commandTable = {
buildCityCommand('che_백성동원', '백성동원'),
buildCityCommand('che_수몰', '수몰'),
buildCityCommand('che_허보', '허보'),
buildNationCommand('che_이호경식', '이호경식'),
buildNationCommand('che_급습', '급습'),
{
...buildNationCommand('che_피장파장', '피장파장'),
inputFields: [
...buildNationCommand('che_피장파장', '피장파장').inputFields,
{
key: 'commandType',
label: '대응 명령',
kind: 'select',
required: true,
options: [{ value: 'che_수몰', label: '수몰' }],
},
],
},
],
},
],
@@ -815,7 +830,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
{ id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] },
],
regionMap: { 1: '하북', 2: '예주' },
levelMap: { 8: '특' },
levelMap: { 8: '특', 7: '대' },
});
if (name === 'auth.status') return response({ ok: true });
if (name === 'lobby.info')
@@ -953,9 +968,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile',
await expect(spy).toBeFocused();
await spy.click();
const form = picker.getByTestId('command-argument-form');
await expect(form.getByTestId('command-argument-guidance')).toContainText(
'선택한 도시에 첩보를 실행합니다.'
);
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 첩보를 실행합니다.');
await expect(form.getByTestId('command-argument-guidance')).toContainText(
'인접 도시에서는 더 많은 정보를 얻습니다.'
);
@@ -979,9 +992,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile',
await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') });
});
test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({
page,
}) => {
test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page }) => {
const foundingColors = [
{ value: 0, label: '색상 1', color: '#FF0000' },
{ value: 15, label: '색상 16', color: '#6495ED' },
@@ -1284,7 +1295,9 @@ test('keeps general and chief command categories after input and across page rel
const reloadedChiefPicker = chiefPage.getByTestId('command-picker');
await expect(reloadedChiefPicker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/);
await expect(reloadedChiefPicker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible();
await expect.poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(200);
await expect
.poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height))
.toBeGreaterThan(200);
await reloadedChiefPicker.screenshot({
path: test.info().outputPath('chief-category-after-reload-mobile-500.png'),
});
@@ -1780,18 +1793,25 @@ test('uses the map to choose a nation target in the chief command window', async
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
});
test('shows city or capital maps for every requested chief command', async ({ page }) => {
test('shows a map and target details for every city or nation argument chief command except assignment', async ({
page,
}) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
const cases = [
{ category: '인사', action: '발령', mode: 'city' },
{ category: '특수', action: '초토화', mode: 'city' },
{ category: '특수', action: '천도', mode: 'city' },
{ category: '특수', action: '증축', mode: 'capital' },
{ category: '특수', action: '감축', mode: 'capital' },
{ category: '전략', action: '수몰', mode: 'city' },
{ category: '전략', action: '허보', mode: 'city' },
{ category: '전략', action: '백성동원', mode: 'city' },
{ category: '외교', action: '원조', mode: 'nation' },
{ category: '외교', action: '불가침 제의', mode: 'nation' },
{ category: '외교', action: '선전포고', mode: 'nation' },
{ category: '외교', action: '종전 제의', mode: 'nation' },
{ category: '외교', action: '불가침 파기 제의', mode: 'nation' },
{ category: '전략', action: '이호경식', mode: 'nation' },
{ category: '전략', action: '급습', mode: 'nation' },
{ category: '전략', action: '피장파장', mode: 'nation' },
];
for (const entry of cases) {
@@ -1805,24 +1825,106 @@ test('shows city or capital maps for every requested chief command', async ({ pa
await expect(map, `${entry.action} 지도`).toBeVisible();
await expect(form.getByTestId('command-argument-guidance')).toBeVisible();
if (entry.mode === 'capital') {
await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 수도업');
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 수도');
await expect(map.locator('.city-base').first()).toHaveJSProperty('tagName', 'DIV');
expect(
await map
.locator('.city-base')
.first()
.evaluate((node) => getComputedStyle(node).cursor)
).toBe('default');
} else {
await map.locator('.city-base').nth(1).click();
await map.locator('.city-base').nth(1).click();
if (entry.mode === 'city') {
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
await expect(form.getByTestId('command-map-target-summary')).toContainText(
'허창 · 적국 · 예주 · 대 · 현재 도시에서 1칸'
);
} else {
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 국가적국');
await expect(form.getByTestId('command-map-target-summary')).toContainText('적국 · 수도 허창 · 도시 1개');
}
await expect(form.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
}
await page.screenshot({ path: test.info().outputPath('chief-command-map-guidance.png'), fullPage: true });
await page.screenshot({ path: test.info().outputPath('chief-command-target-map-details.png'), fullPage: true });
await page.setViewportSize({ width: 500, height: 900 });
await page.goto('/che/chief-center');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?전략$/, exact: true }).click();
await picker.getByRole('button', { name: /피장파장/ }).click();
const form = picker.getByTestId('command-argument-form');
const map = form.getByTestId('command-argument-map');
const targetCity = map.locator('.city-base').nth(1);
await targetCity.hover();
expect(await targetCity.evaluate((node) => getComputedStyle(node).cursor)).toBe('pointer');
await targetCity.focus();
await expect(targetCity).toBeFocused();
await targetCity.click();
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
const geometry = await picker.evaluate((element) => ({
pickerWidth: element.getBoundingClientRect().width,
pickerOverflow: element.scrollWidth - element.clientWidth,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
mapInsidePicker:
element.querySelector<HTMLElement>('[data-testid="command-argument-map"]')!.getBoundingClientRect().right <=
element.getBoundingClientRect().right,
}));
expect(geometry).toEqual({ pickerWidth: 500, pickerOverflow: 0, documentOverflow: 0, mapInsidePicker: true });
await page.screenshot({
path: test.info().outputPath('chief-command-target-map-details-mobile.png'),
fullPage: true,
});
});
test('prioritizes own cities for assignment while retaining other map targets', async ({ page }) => {
const assignmentTable = structuredClone(commandTable);
assignmentTable.inputOptions.cities = [
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
{ value: 3, label: '단양 (무주)' },
{ value: 1, label: '업 (아국)' },
];
await install(page, false, assignmentTable);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/che/chief-center');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
await picker.getByRole('button', { name: /발령/ }).click();
const form = picker.getByTestId('command-argument-form');
const citySelect = form.locator('#command-arg-destCityId');
await expect(form.getByTestId('command-argument-map')).toBeVisible();
await expect(citySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']);
await expect(citySelect).toHaveValue('1');
await form.getByTestId('command-argument-map').locator('.city-base').nth(1).click();
await expect(citySelect).toHaveValue('2');
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
await expect(page).toHaveURL(/\/che\/chief-center$/);
await form.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority.png') });
await page.setViewportSize({ width: 500, height: 900 });
await page.goto('/che/chief-center');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const mobilePicker = page.getByTestId('command-picker');
await mobilePicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
await mobilePicker.getByRole('button', { name: /발령/ }).click();
const mobileForm = mobilePicker.getByTestId('command-argument-form');
const mobileMap = mobileForm.getByTestId('command-argument-map');
const mobileCitySelect = mobileForm.locator('#command-arg-destCityId');
await expect(mobileMap).toBeVisible();
await expect(mobileCitySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']);
const mobileGeometry = await mobilePicker.evaluate((element) => ({
width: element.getBoundingClientRect().width,
overflow: element.scrollWidth - element.clientWidth,
}));
expect(mobileGeometry).toEqual({ width: 500, overflow: 0 });
const mapGeometry = await mobileMap.locator('.map-area').evaluate((element) => {
const rect = element.getBoundingClientRect();
return { width: rect.width, height: rect.height };
});
expect(mapGeometry.width / mapGeometry.height).toBeCloseTo(7 / 5, 2);
await mobileMap.screenshot({ path: test.info().outputPath('chief-assignment-map-mobile.png') });
await mobileCitySelect.scrollIntoViewIfNeeded();
await mobilePicker.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority-mobile.png') });
});
test('prioritizes current nation targets while preserving every choice', async ({ page }) => {
+106
View File
@@ -467,6 +467,112 @@ test('국가 정보의 작위는 Ref 국가 등급 이름으로 표시된다', a
await expect(root).not.toContainText('작 위1');
});
test('map keeps desktop hover navigation and lets touch users choose one-tap or two-tap city navigation', async ({
browser,
page,
}, testInfo) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await go(page, 'global-info');
const desktopCity = page.locator('.city-base').first();
await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0);
await desktopCity.hover();
await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업');
await desktopCity.click();
await expect(page).toHaveURL(/\/current-city\?cityId=1$/u);
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') {
throw new Error('Playwright baseURL is required for the mobile map contract');
}
const context = await browser.newContext({
baseURL: configuredBaseUrl,
viewport: { width: 390, height: 844 },
screen: { width: 390, height: 844 },
deviceScaleFactor: 1,
isMobile: true,
hasTouch: true,
colorScheme: 'dark',
});
const mobilePage = await context.newPage();
try {
await install(mobilePage);
await go(mobilePage, 'global-info');
const twoTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' });
await expect(twoTapButton).toBeVisible();
await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false');
const controlGeometry = await twoTapButton.evaluate((element) => {
const rect = element.getBoundingClientRect();
const mapRect = element.closest('.map-area')?.getBoundingClientRect();
const style = getComputedStyle(element);
return {
right: rect.right,
bottom: rect.bottom,
mapRight: mapRect?.right,
mapBottom: mapRect?.bottom,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
documentWidth: document.documentElement.scrollWidth,
viewportWidth: document.documentElement.clientWidth,
overflowing: Array.from(document.querySelectorAll<HTMLElement>('body *'))
.filter(
(candidate) => candidate.getBoundingClientRect().right > document.documentElement.clientWidth
)
.map((candidate) => ({
tag: candidate.tagName,
className: candidate.className,
right: candidate.getBoundingClientRect().right,
}))
.slice(0, 8),
};
});
expect(controlGeometry).toMatchObject({
fontSize: '11px',
lineHeight: '18px',
viewportWidth: 500,
overflowing: [],
});
expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1);
expect(controlGeometry.mapRight! - controlGeometry.right).toBeCloseTo(4, 1);
expect(controlGeometry.mapBottom! - controlGeometry.bottom).toBeCloseTo(4, 1);
const mobileCities = mobilePage.locator('.city-base');
await mobileCities.nth(0).tap();
await expect(mobilePage).toHaveURL(/\/global-info$/u);
await expect(mobilePage.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업');
await mobileCities.nth(1).tap();
await expect(mobilePage).toHaveURL(/\/global-info$/u);
await expect(mobilePage.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|수】성2');
await mobilePage.screenshot({ path: testInfo.outputPath('mobile-map-first-tap-tooltip.png'), fullPage: true });
await mobileCities.nth(1).tap();
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=2$/u);
await go(mobilePage, 'global-info');
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click();
const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' });
await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true');
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes');
await mobilePage.reload();
await expect(singleTapButton).toBeVisible();
await mobilePage.locator('.city-base').nth(2).tap();
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u);
await go(mobilePage, 'global-info');
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click();
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no');
await mobilePage.locator('.city-base').first().tap();
await expect(mobilePage).toHaveURL(/\/global-info$/u);
} finally {
await context.close();
}
});
test('global-info renders the ref nation summary columns beside the map', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
+65
View File
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, resolve } from 'node:path';
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
import { touchDrag } from './touchDrag.js';
const response = (data: unknown) => ({ result: { data } });
const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR;
@@ -1469,6 +1470,70 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버
.toEqual(defaultOrder);
});
test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => {
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') {
throw new Error('Playwright baseURL is required for the mobile touch contract');
}
const context = await browser.newContext({
baseURL: configuredBaseUrl,
viewport: { width: 390, height: 844 },
screen: { width: 390, height: 844 },
deviceScaleFactor: 1,
isMobile: true,
hasTouch: true,
colorScheme: 'dark',
});
const mobilePage = await context.newPage();
try {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(mobilePage, state);
await mobilePage.goto('my-page');
await mobilePage.getByRole('button', { name: '순서 바꾸기', exact: true }).click();
const dialog = mobilePage.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' });
const commands = dialog.locator('[data-mobile-layout-id="commands"]');
const nationMenu = dialog.locator('[data-mobile-layout-id="nation-menu"]');
await touchDrag(mobilePage, nationMenu, commands);
await expect
.poll(() =>
dialog
.locator('[data-mobile-layout-id]')
.evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-layout-id')))
)
.toEqual([
'nation-menu',
'commands',
'nation',
'general',
'city',
'map',
'records',
'global-menu',
'messages',
]);
await dialog.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch-dialog.png') });
await dialog.getByRole('button', { name: '적용', exact: true }).click();
await expect
.poll(() => mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]')))
.toEqual([
'nation-menu',
'commands',
'nation',
'general',
'city',
'map',
'records',
'global-menu',
'messages',
]);
await mobilePage.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch.png'), fullPage: true });
} finally {
await context.close();
}
});
for (const [label, failure] of [
['daemon timeout', 'TIMEOUT'],
['engine transaction 오류', 'INTERNAL_SERVER_ERROR'],
+195 -8
View File
@@ -885,9 +885,9 @@ const expectMobilePanelVisualOrder = async (page: Page, expectedOrder: readonly
expect(audit.panels.map(({ id }) => id)).toEqual(expectedOrder);
expect(audit.visualOrder).toEqual(expectedOrder);
expect(audit.panels.every(({ left, right, width }) => left >= 0 && right <= 500 && width === 500)).toBe(true);
expect(
audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom)
).toBe(true);
expect(audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom)).toBe(
true
);
for (const panel of audit.panels) {
expect(panel.display, `${panel.id}: display`).not.toBe('none');
expect(['static', 'relative'], `${panel.id}: position`).toContain(panel.position);
@@ -1102,7 +1102,9 @@ test('scopes the new-survey notice cursor to the reset-specific server ID', asyn
expect(await page.evaluate(() => localStorage.getItem('state.che.lastVote'))).toBe('99');
});
test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ page }) => {
test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({
page,
}, testInfo) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
@@ -1125,9 +1127,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(page.locator('.main-mobile-bottom')).toBeHidden();
await expect(page.locator('.layout-desktop')).toBeVisible();
await expect(page.locator('.layout-mobile')).toHaveCount(0);
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount(
1
);
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount(1);
await expect(page.locator('.game-shell__subtitle')).toHaveCount(0);
await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월');
await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분');
@@ -1252,6 +1252,34 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
const versionDialog = page.getByRole('dialog', { name: '게임 정보' });
await expect(versionDialog).toBeVisible();
await expect(versionDialog).toContainText('메인 화면 검증 시나리오');
await expect(versionDialog.getByText('빌드 커밋', { exact: true })).toBeVisible();
await expect(versionDialog.locator('code')).toHaveText('0123456789abcdef0123456789abcdef01234567');
const versionGeometry = await versionDialog.evaluate((dialog) => {
const code = dialog.querySelector('code');
if (!code) throw new Error('game version commit is missing');
const dialogStyle = getComputedStyle(dialog);
const codeStyle = getComputedStyle(code);
return {
dialog: dialog.getBoundingClientRect().toJSON(),
code: code.getBoundingClientRect().toJSON(),
dialogBackground: dialogStyle.backgroundColor,
dialogColor: dialogStyle.color,
codeColor: codeStyle.color,
codeFontFamily: codeStyle.fontFamily,
viewportWidth: window.innerWidth,
};
});
expect(versionGeometry.dialog.width).toBeLessThanOrEqual(versionGeometry.viewportWidth - 32);
expect(versionGeometry.code.left).toBeGreaterThanOrEqual(versionGeometry.dialog.left);
expect(versionGeometry.code.right).toBeLessThanOrEqual(versionGeometry.dialog.right);
expect(versionGeometry.dialogBackground).toBe('rgb(32, 32, 32)');
expect(versionGeometry.dialogColor).toBe('rgb(255, 255, 255)');
expect(versionGeometry.codeColor).toBe('rgb(215, 215, 215)');
await writeFile(
testInfo.outputPath('desktop-game-version-dialog.json'),
`${JSON.stringify(versionGeometry, null, 2)}\n`
);
await versionDialog.screenshot({ path: testInfo.outputPath('desktop-game-version-dialog.png') });
await versionDialog.getByRole('button', { name: '닫기' }).click();
await expect(versionDialog).toBeHidden();
@@ -1279,7 +1307,9 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
});
test('shows the persisted official game index beside the scenario title without viewport overflow', async ({ page }) => {
test('shows the persisted official game index beside the scenario title without viewport overflow', async ({
page,
}) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
@@ -1479,6 +1509,30 @@ test('the repeated bottom global menu opens upward on the mobile document', asyn
expect(geometry.caretBorderTopWidth).toBe('0px');
expect(geometry.caretBorderBottomWidth).toBe('4px');
await bottomGlobal.screenshot({ path: testInfo.outputPath('mobile-bottom-global-dropup.png') });
await page.setViewportSize({ width: 390, height: 844 });
await bottomGlobal.locator('[data-navigation-id="version"]').click();
const versionDialog = page.getByRole('dialog', { name: '게임 정보' });
await expect(versionDialog).toBeVisible();
await expect(versionDialog.locator('code')).toHaveText('0123456789abcdef0123456789abcdef01234567');
const versionGeometry = await versionDialog.evaluate((dialog) => {
const code = dialog.querySelector('code');
if (!code) throw new Error('game version commit is missing');
return {
dialog: dialog.getBoundingClientRect().toJSON(),
code: code.getBoundingClientRect().toJSON(),
viewportWidth: window.innerWidth,
documentScrollWidth: document.documentElement.scrollWidth,
};
});
expect(versionGeometry.dialog.width).toBeLessThanOrEqual(versionGeometry.viewportWidth - 32);
expect(versionGeometry.code.left).toBeGreaterThanOrEqual(versionGeometry.dialog.left);
expect(versionGeometry.code.right).toBeLessThanOrEqual(versionGeometry.dialog.right);
expect(versionGeometry.documentScrollWidth).toBe(500);
await writeFile(
testInfo.outputPath('mobile-game-version-dialog.json'),
`${JSON.stringify(versionGeometry, null, 2)}\n`
);
await versionDialog.screenshot({ path: testInfo.outputPath('mobile-game-version-dialog.png') });
await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-dropup`);
});
@@ -2794,6 +2848,139 @@ test('real mobile devices initially fit the complete 500px game canvas', async (
}
});
test('automatic screen mode switches wide mobile screens to the 1000px layout at the Ref boundary', async ({
browser,
}, testInfo) => {
test.setTimeout(60_000);
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') {
throw new Error('Playwright baseURL is required for the automatic screen-mode contract');
}
const measurements: Record<string, unknown> = {};
for (const deviceWidth of [699, 700, 820]) {
const context = await browser.newContext({
baseURL: configuredBaseUrl,
viewport: { width: deviceWidth, height: 1180 },
screen: { width: deviceWidth, height: 1180 },
deviceScaleFactor: 1,
isMobile: true,
hasTouch: true,
colorScheme: 'dark',
});
const mobilePage = await context.newPage();
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 6,
npcMode: 1,
generalMeCalls: 0,
operations: [],
};
await installFixture(mobilePage, state);
await waitForMain(mobilePage);
const expectedWideLayout = deviceWidth >= 700;
await expect(mobilePage.locator(expectedWideLayout ? '.layout-desktop' : '.layout-mobile')).toBeVisible();
expect(
await mobilePage.evaluate(() => document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content)
).toBe(expectedWideLayout ? 'width=1000' : 'width=device-width, initial-scale=1');
const modeMeasurements: Record<string, unknown> = {
auto: await mobilePage.locator('.main-page').evaluate((element) => {
const rect = element.getBoundingClientRect();
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
return {
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
screenWidth: screen.availWidth,
innerWidth: window.innerWidth,
layoutViewportWidth: document.documentElement.clientWidth,
visualViewportWidth: window.visualViewport?.width ?? null,
visualViewportScale: window.visualViewport?.scale ?? null,
mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null,
desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null,
canvas: { left: rect.left, right: rect.right, width: rect.width },
};
}),
};
if (deviceWidth === 820) {
await mobilePage.evaluate(() => {
localStorage.setItem('sam.screenMode', '500px');
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
});
await expect(mobilePage.locator('.layout-mobile')).toBeVisible();
expect(
await mobilePage.evaluate(
() => document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content
)
).toBe('width=500');
modeMeasurements.forced500 = await mobilePage.locator('.main-page').evaluate(() => {
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
return {
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
layoutViewportWidth: document.documentElement.clientWidth,
visualViewportWidth: window.visualViewport?.width ?? null,
mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null,
desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null,
};
});
await mobilePage.evaluate(() => {
localStorage.setItem('sam.screenMode', '1000px');
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
});
await expect(mobilePage.locator('.layout-desktop')).toBeVisible();
expect(
await mobilePage.evaluate(
() => document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content
)
).toBe('width=1000');
modeMeasurements.forced1000 = await mobilePage.locator('.main-page').evaluate(() => {
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
return {
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
layoutViewportWidth: document.documentElement.clientWidth,
visualViewportWidth: window.visualViewport?.width ?? null,
mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null,
desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null,
};
});
await mobilePage.evaluate(() => {
localStorage.setItem('sam.screenMode', 'auto');
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
});
await expect(mobilePage.locator('.layout-desktop')).toBeVisible();
expect(
await mobilePage.evaluate(
() => document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content
)
).toBe('width=1000');
}
measurements[String(deviceWidth)] = modeMeasurements;
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await mobilePage.screenshot({
path: resolve(artifactRoot, `auto-screen-mode-${deviceWidth}.png`),
fullPage: true,
});
}
await context.close();
}
if (artifactRoot) {
await writeFile(
resolve(artifactRoot, 'auto-screen-mode-computed-dom.json'),
`${JSON.stringify(measurements, null, 2)}\n`
);
}
});
test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 1,
@@ -294,8 +294,19 @@ test('nation generals filter buttons open Ref operator menus and apply compound
await page.setViewportSize({ width: 500, height: 900 });
expect(await page.locator('.general-page').evaluate((element) => element.getBoundingClientRect().width)).toBe(1000);
const generalSearch = page.getByLabel('장수명 필터');
await expect(generalSearch).toHaveCSS('touch-action', 'manipulation');
const viewportContract = await page.evaluate(() => ({
content: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content ?? '',
scale: window.visualViewport?.scale ?? 1,
}));
expect(viewportContract.content).not.toMatch(/(?:user-scalable|minimum-scale|maximum-scale)/u);
await generalSearch.focus();
await expect(generalSearch).toBeFocused();
expect(await page.evaluate(() => window.visualViewport?.scale ?? 1)).toBe(viewportContract.scale);
await nameMenuButton.click();
await expect(namePopup).toBeVisible();
await expect(page.getByLabel('장수명 첫 번째 필터 값')).toHaveCSS('touch-action', 'manipulation');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000);
await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true });
});
+53
View File
@@ -3,6 +3,7 @@ import { mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
import { touchDrag } from './touchDrag.js';
type FixtureState = {
permissionLevel: number;
@@ -320,6 +321,58 @@ test('500px layout stacks policy fields and priority panels like the reference',
await screenshot(page, 'core-npc-policy-mobile.png');
});
test('physical mobile touch reorders NPC priority across active and inactive lists', async ({ browser }, testInfo) => {
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') {
throw new Error('Playwright baseURL is required for the mobile touch contract');
}
const context = await browser.newContext({
baseURL: configuredBaseUrl,
viewport: { width: 390, height: 844 },
screen: { width: 390, height: 844 },
deviceScaleFactor: 1,
isMobile: true,
hasTouch: true,
colorScheme: 'dark',
});
const mobilePage = await context.newPage();
try {
await installFixture(mobilePage, { permissionLevel: 4, mutations: [] });
await gotoPolicy(mobilePage);
await expect(mobilePage.locator('#container')).toBeVisible();
const nationPanel = mobilePage.locator('.priority-panel').first();
const activeList = nationPanel.locator('.priority-column').nth(1).locator('.priority-list');
const activeRows = activeList.locator('.priority-item');
await touchDrag(
mobilePage,
activeRows.nth(0),
activeRows.nth(3),
{ targetYRatio: 0.9 }
);
await expect
.poll(() =>
activeList
.locator('.priority-item .priority_info > span:nth-child(2)')
.first()
.textContent()
)
.toBe('선전포고');
const activeItem = activeList.getByText('불가침제의', { exact: true });
const inactiveList = nationPanel.locator('.priority-column').first().locator('.priority-list');
await touchDrag(mobilePage, activeItem, inactiveList.locator('.inactive-header'));
await expect(inactiveList.getByText('불가침제의', { exact: true })).toBeVisible();
await expect(
activeList.getByText('불가침제의', { exact: true })
).toHaveCount(0);
await mobilePage.screenshot({ path: testInfo.outputPath('npc-priority-mobile-touch.png'), fullPage: true });
} finally {
await context.close();
}
});
test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => {
const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] };
await installFixture(page, state);
+2 -1
View File
@@ -9,11 +9,12 @@ const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
const baseURL = `http://127.0.0.1:${port}${basePath}/`;
const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? `${basePath}/api/trpc`;
const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/';
const buildCommitSha = process.env.PLAYWRIGHT_BUILD_COMMIT_SHA ?? '0123456789abcdef0123456789abcdef01234567';
const useProductionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
const frontendEnv =
`VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} ` +
`VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} ` +
'VITE_GATEWAY_API_URL=/gateway/api/trpc';
`VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_BUILD_COMMIT_SHA=${buildCommitSha}`;
export default defineConfig({
testDir: '.',
+77
View File
@@ -0,0 +1,77 @@
import type { Locator, Page } from '@playwright/test';
type TouchPoint = {
x: number;
y: number;
};
type TouchDragOptions = {
targetYRatio?: number;
};
const pointIn = async (locator: Locator, yRatio = 0.5): Promise<TouchPoint> => {
const box = await locator.boundingBox();
if (!box) {
throw new Error('Touch drag target has no visible bounding box');
}
return {
x: box.x + box.width / 2,
y: box.y + box.height * yRatio,
};
};
export const touchDrag = async (
page: Page,
source: Locator,
target: Locator,
options: TouchDragOptions = {}
): Promise<void> => {
await source.scrollIntoViewIfNeeded();
await target.scrollIntoViewIfNeeded();
const from = await pointIn(source);
const to = await pointIn(target, options.targetYRatio);
const cdp = await page.context().newCDPSession(page);
await page.evaluate(() => {
document.documentElement.removeAttribute('data-playwright-touch-trusted');
document.addEventListener(
'touchstart',
(event) => document.documentElement.setAttribute('data-playwright-touch-trusted', String(event.isTrusted)),
{ capture: true, once: true }
);
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ ...from, id: 0, radiusX: 1, radiusY: 1, force: 1 }],
});
await page.waitForTimeout(50);
const dispatchMove = async (ratio: number) => {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{
x: from.x + (to.x - from.x) * ratio,
y: from.y + (to.y - from.y) * ratio,
id: 0,
radiusX: 1,
radiusY: 1,
force: 1,
},
],
});
};
await dispatchMove(0.05);
await page.waitForTimeout(100);
for (let step = 2; step <= 20; step += 1) {
await dispatchMove(step / 20);
await page.waitForTimeout(16);
}
await page.waitForTimeout(50);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const trusted = await page.evaluate(
() => document.documentElement.getAttribute('data-playwright-touch-trusted') === 'true'
);
if (!trusted) {
throw new Error('Chromium did not dispatch a trusted touchstart event');
}
};
+1
View File
@@ -48,6 +48,7 @@
"mitt": "^3.0.1",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vuedraggable-es": "4.1.1",
"vue-router": "^4.6.4",
"zod": "^4.3.5"
},
+9
View File
@@ -63,6 +63,15 @@ textarea {
font: inherit;
}
/*
* Firefox for Android may zoom to a focused search field. `manipulation`
* suppresses that focus-only zoom while retaining ordinary pan and pinch zoom.
*/
input[type='search'],
input[inputmode='search'] {
touch-action: manipulation;
}
/*
* Ref's `.bg0/.bg1/.bg2` set a background image and nothing else, so the
* element stays transparent where the texture does not cover it. Adding a
@@ -0,0 +1,21 @@
import type { CommandMapData, CommandOption } from './types';
export const commandCityOptions = (
commandKey: string,
options: readonly CommandOption[],
mapData?: CommandMapData | null
): CommandOption[] => {
if (commandKey !== 'che_발령' || typeof mapData?.myNation !== 'number') return [...options];
const nationByCityId = new Map(mapData.cityList.map(([cityId, , , nationId]) => [cityId, nationId]));
return options
.map((option, index) => ({ option, index }))
.sort((left, right) => {
const leftOwned =
typeof left.option.value === 'number' && nationByCityId.get(left.option.value) === mapData.myNation;
const rightOwned =
typeof right.option.value === 'number' && nationByCityId.get(right.option.value) === mapData.myNation;
return Number(rightOwned) - Number(leftOwned) || left.index - right.index;
})
.map(({ option }) => option);
};
@@ -1,6 +1,10 @@
import type { CommandInputField } from './types';
export type CommandArgumentMapTarget = 'city' | 'nation' | 'capital';
export type CommandArgumentPresentation = {
lines: string[];
mapTarget?: 'city' | 'nation' | 'capital';
mapTarget?: CommandArgumentMapTarget;
};
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
@@ -98,4 +102,18 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation =>
PRESENTATIONS[commandKey] ?? { lines: [] };
/**
* API가 .
* · presentation의 target을 .
*/
export const resolveCommandArgumentMapTarget = (
commandKey: string,
fields: readonly CommandInputField[]
): CommandArgumentMapTarget | undefined => {
const selectableTargets = fields.filter((field) => field.kind === 'select');
if (selectableTargets.some((field) => field.optionSource === 'cities')) return 'city';
if (selectableTargets.some((field) => field.optionSource === 'nations')) return 'nation';
return commandArgumentPresentation(commandKey).mapTarget;
};
export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS);
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, reactive, watch, type CSSProperties } from 'vue';
import MapViewer from './MapViewer.vue';
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation';
import { commandCityOptions } from '../command/commandArgumentOptions';
import {
commandArgumentFieldContract,
shouldPreserveCommandArgumentValue,
@@ -64,6 +65,9 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
if (field.optionSource === 'nations') {
return props.options.nationTargets?.[props.commandKey] ?? props.options.nations;
}
if (field.optionSource === 'cities') {
return commandCityOptions(props.commandKey, props.options.cities, props.mapData);
}
if (field.optionSource === 'items') {
return props.options.items[String(values.itemType ?? '')] ?? [];
}
@@ -104,12 +108,7 @@ const synchronizeValues = () => {
for (const field of props.fields) {
const preserve =
!commandChanged &&
shouldPreserveCommandArgumentValue(
field,
previousFieldContracts.get(field.key),
values,
optionsFor(field)
);
shouldPreserveCommandArgumentValue(field, previousFieldContracts.get(field.key), values, optionsFor(field));
if (!preserve) values[field.key] = defaultValue(field);
}
const itemCodeField = props.fields.find((field) => field.key === 'itemCode');
@@ -162,20 +161,15 @@ const nationTargetField = computed(() =>
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
)
);
const showMap = computed(
() =>
Boolean(props.mapData && props.mapLayout) &&
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
(presentation.value.mapTarget === 'nation' && nationTargetField.value) ||
presentation.value.mapTarget === 'capital')
);
const mapTarget = computed(() => resolveCommandArgumentMapTarget(props.commandKey, props.fields));
const showMap = computed(() => Boolean(props.mapData && props.mapLayout && mapTarget.value));
const mapSelectedCityId = computed<number | null>(() => {
if (!props.mapData) return null;
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
if (mapTarget.value === 'city' && cityTargetField.value) {
const value = values[cityTargetField.value.key];
return typeof value === 'number' ? value : null;
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
if (mapTarget.value === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return null;
return (
@@ -184,7 +178,7 @@ const mapSelectedCityId = computed<number | null>(() => {
null
);
}
if (presentation.value.mapTarget === 'capital') {
if (mapTarget.value === 'capital') {
const myNation = props.mapData.myNation;
return props.mapData.nationList.find((entry) => entry[0] === myNation)?.[3] ?? null;
}
@@ -198,17 +192,17 @@ const currentCityName = computed(() => {
});
const selectedMapTargetName = computed(() => {
if (presentation.value.mapTarget === 'city') {
if (mapTarget.value === 'city') {
const cityId = mapSelectedCityId.value;
if (!cityId) return '-';
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
if (mapTarget.value === 'nation' && nationTargetField.value) {
const nationId = values[nationTargetField.value.key];
if (typeof nationId !== 'number') return '-';
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-';
}
if (presentation.value.mapTarget === 'capital') {
if (mapTarget.value === 'capital') {
const cityId = mapSelectedCityId.value;
if (!cityId) return '-';
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
@@ -240,7 +234,7 @@ const distanceFromMyCity = (destination: number): number | null => {
const mapTargetSummary = computed(() => {
if (!props.mapData || !props.mapLayout) return '';
if (presentation.value.mapTarget === 'city' && mapSelectedCityId.value) {
if (mapTarget.value === 'city' && mapSelectedCityId.value) {
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
if (!city) return '';
@@ -256,7 +250,7 @@ const mapTargetSummary = computed(() => {
.filter(Boolean)
.join(' · ');
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
if (mapTarget.value === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return '';
const nation = props.mapData.nationList.find((entry) => entry[0] === value);
@@ -265,7 +259,7 @@ const mapTargetSummary = computed(() => {
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}`;
}
if (presentation.value.mapTarget === 'capital' && mapSelectedCityId.value) {
if (mapTarget.value === 'capital' && mapSelectedCityId.value) {
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
if (!city) return '';
@@ -278,11 +272,11 @@ const mapTargetSummary = computed(() => {
const selectMapCity = (cityId: number) => {
if (!props.mapData) return;
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
if (mapTarget.value === 'city' && cityTargetField.value) {
setSelectValue(cityTargetField.value, String(cityId));
return;
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
if (mapTarget.value === 'nation' && nationTargetField.value) {
const nationId = props.mapData.cityList.find((entry) => entry[0] === cityId)?.[3];
if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId));
}
@@ -417,24 +411,20 @@ watch(
:detail-mode="true"
:fit-container="true"
:show-current-city-marker="true"
:readonly="presentation.mapTarget === 'capital'"
:readonly="mapTarget === 'capital'"
@select-city="selectMapCity"
/>
<small v-if="presentation.mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
<small v-if="mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
<small v-else>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
<span v-if="presentation.mapTarget !== 'capital'" class="current-city-status">
<span v-if="mapTarget !== 'capital'" class="current-city-status">
<span class="status-key">현재 도시</span>
<strong>{{ currentCityName }}</strong>
</span>
<span v-if="presentation.mapTarget !== 'capital'" aria-hidden="true"></span>
<span v-if="mapTarget !== 'capital'" aria-hidden="true"></span>
<span class="selected-target-status">
<span class="status-key">{{
presentation.mapTarget === 'nation'
? '선택 국가'
: presentation.mapTarget === 'capital'
? '현재 수도'
: '선택 도시'
mapTarget === 'nation' ? '선택 국가' : mapTarget === 'capital' ? '현재 수도' : '선택 도시'
}}</span>
<strong>{{ selectedMapTargetName }}</strong>
</span>
@@ -28,6 +28,8 @@ const props = defineProps<{
const emit = defineEmits<{
(event: 'hover', cityId: number): void;
(event: 'leave'): void;
(event: 'touch', cityId: number, touchEvent: TouchEvent): void;
(event: 'touchleave'): void;
(event: 'select', cityId: number): void;
}>();
@@ -37,6 +39,25 @@ const stateOffset = computed(() => -6 * props.mapScale);
const selectCity = () => {
if (!props.readonly) emit('select', props.city.id);
};
let touchOnTrack = false;
const touchstart = () => {
touchOnTrack = true;
};
const touchmove = () => {
touchOnTrack = false;
};
const touchend = (event: TouchEvent) => {
if (touchOnTrack) {
event.stopPropagation();
emit('touch', props.city.id, event);
return;
}
emit('touchleave');
};
</script>
<template>
@@ -59,6 +80,9 @@ const selectCity = () => {
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@touchstart="touchstart"
@touchmove="touchmove"
@touchend="touchend"
@click.stop="selectCity"
>
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
@@ -54,6 +54,8 @@ const props = defineProps<{
const emit = defineEmits<{
(event: 'hover', cityId: number): void;
(event: 'leave'): void;
(event: 'touch', cityId: number, touchEvent: TouchEvent): void;
(event: 'touchleave'): void;
(event: 'select', cityId: number): void;
}>();
@@ -148,6 +150,25 @@ const selectCity = () => {
if (!props.readonly) emit('select', props.city.id);
};
let touchOnTrack = false;
const touchstart = () => {
touchOnTrack = true;
};
const touchmove = () => {
touchOnTrack = false;
};
const touchend = (event: TouchEvent) => {
if (touchOnTrack) {
event.stopPropagation();
emit('touch', props.city.id, event);
return;
}
emit('touchleave');
};
const cityStateStyle = computed(() => ({
width: `${12 * props.mapScale}px`,
height: `${12 * props.mapScale}px`,
@@ -174,6 +195,9 @@ const cityStateStyle = computed(() => ({
:style="cityBaseStyle"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@touchstart="touchstart"
@touchmove="touchmove"
@touchend="touchend"
@click.stop="selectCity"
>
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
@@ -94,9 +94,11 @@ const mapStore = useMapViewerStore();
const {
showCityName,
detailMode: storeDetailMode,
singleTapNavigation,
hoveredCityId,
selectedCityId: storeSelectedCityId,
} = storeToRefs(mapStore);
const hasTouchInput = useMediaQuery('(any-pointer: coarse)');
const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null);
@@ -404,6 +406,28 @@ const setHoveredCity = (cityId: number | null) => {
mapStore.setHoveredCity(cityId);
};
const touchPreviewCityId = ref<number | null>(null);
const clearTouchPreview = () => {
touchPreviewCityId.value = null;
setHoveredCity(null);
};
const touchCity = (cityId: number, event: TouchEvent) => {
if (touchPreviewCityId.value !== cityId) {
touchPreviewCityId.value = cityId;
setHoveredCity(cityId);
if (!singleTapNavigation.value) {
event.preventDefault();
}
}
};
const toggleSingleTapNavigation = () => {
clearTouchPreview();
mapStore.toggleSingleTapNavigation();
};
const selectCity = (cityId: number) => {
if (props.readonly) return;
emit('select-city', cityId);
@@ -433,6 +457,7 @@ const selectCity = (cityId: number) => {
class="map-area"
:class="[mapThemeClass, mapSeasonClass]"
:style="{ width: mapWidth, height: mapHeight }"
@click="clearTouchPreview"
>
<div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
<div class="map-layer map-bglayer2" />
@@ -449,6 +474,8 @@ const selectCity = (cityId: number) => {
v-bind="detailProps"
@hover="setHoveredCity"
@leave="setHoveredCity(null)"
@touch="touchCity"
@touchleave="clearTouchPreview"
@select="selectCity"
/>
<div
@@ -466,9 +493,18 @@ const selectCity = (cityId: number) => {
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
</div>
<div class="map-controls">
<button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
<button class="map-toggle" :class="{ active: showCityName }" @click.stop="mapStore.toggleCityName">
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
</button>
<button
v-if="hasTouchInput"
class="map-toggle map-toggle-single-tap"
:class="{ active: singleTapNavigation }"
:aria-pressed="singleTapNavigation"
@click.stop="toggleSingleTapNavigation"
>
두번 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
</button>
</div>
</div>
</div>
@@ -555,6 +591,8 @@ const selectCity = (cityId: number) => {
right: 4px;
bottom: 4px;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.map-toggle {
@@ -0,0 +1,43 @@
import { defineComponent, h, type PropType, type SlotsType, type VNode } from 'vue';
import VueDraggable from 'vuedraggable-es';
export default defineComponent({
name: 'SortableStringList',
inheritAttrs: false,
props: {
list: {
type: Array as PropType<string[]>,
required: true,
},
group: {
type: String,
default: undefined,
},
tag: {
type: String,
default: 'div',
},
},
slots: Object as SlotsType<{
header?: () => VNode[];
item: (props: { element: string; index: number }) => VNode[];
}>,
setup(props, { attrs, slots }) {
return () =>
h(
VueDraggable,
{
...attrs,
list: props.list,
group: props.group,
itemKey: (item: string) => item,
tag: props.tag,
},
{
header: () => slots.header?.(),
item: ({ element, index }: { element: string; index: number }) =>
slots.item({ element, index }),
}
);
},
});
+1
View File
@@ -22,6 +22,7 @@ interface ImportMetaEnv {
readonly VITE_BOARD_PATCH_URL?: string;
readonly VITE_OFFICIAL_CHAT_URL?: string;
readonly VITE_CASUAL_CHAT_URL?: string;
readonly VITE_BUILD_COMMIT_SHA?: string;
}
interface ImportMeta {
+2
View File
@@ -4,8 +4,10 @@ import App from './App.vue';
import router from './router';
import './assets/main.css';
import { installImageAssetCssVariables } from './utils/imageAssets';
import { installScreenModeViewport } from './utils/screenModeViewport';
installImageAssetCssVariables();
installScreenModeViewport();
const app = createApp(App);
@@ -220,7 +220,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
label: '외교메시지',
color: '#000000',
options: contacts
.filter((nation) => nation.mailbox !== ownMailbox && nation.nationId > 0)
.filter((nation) => nation.mailbox !== ownMailbox)
.map((nation) => ({
label: nation.name,
value: nation.mailbox,
+15
View File
@@ -3,14 +3,23 @@ import { defineStore } from 'pinia';
interface MapViewerState {
showCityName: boolean;
detailMode: boolean;
singleTapNavigation: boolean;
hoveredCityId: number | null;
selectedCityId: number | null;
}
const SINGLE_TAP_STORAGE_KEY = 'sam.toggleSingleTap';
const loadSingleTapNavigation = (): boolean => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem(SINGLE_TAP_STORAGE_KEY) === 'yes';
};
export const useMapViewerStore = defineStore('mapViewer', {
state: (): MapViewerState => ({
showCityName: true,
detailMode: true,
singleTapNavigation: loadSingleTapNavigation(),
hoveredCityId: null,
selectedCityId: null,
}),
@@ -21,6 +30,12 @@ export const useMapViewerStore = defineStore('mapViewer', {
toggleDetailMode() {
this.detailMode = !this.detailMode;
},
toggleSingleTapNavigation() {
this.singleTapNavigation = !this.singleTapNavigation;
if (typeof window !== 'undefined') {
window.localStorage.setItem(SINGLE_TAP_STORAGE_KEY, this.singleTapNavigation ? 'yes' : 'no');
}
},
setHoveredCity(cityId: number | null) {
this.hoveredCityId = cityId;
},
@@ -0,0 +1,78 @@
export const SCREEN_MODE_KEY = 'sam.screenMode';
export const SCREEN_MODE_CHANGE_EVENT = 'tryChangeScreenMode';
export type ScreenMode = 'auto' | '500px' | '1000px';
export type AutoViewportMeasurements = {
deviceWidth: number;
viewportHeight: number;
targetHeight?: number;
};
export const normalizeScreenMode = (value: string | null): ScreenMode =>
value === '500px' || value === '1000px' ? value : 'auto';
export const resolveAutoViewportContent = ({
deviceWidth,
viewportHeight,
targetHeight = 700,
}: AutoViewportMeasurements): string => {
if (deviceWidth < 500) {
return 'width=500';
}
if (viewportHeight < targetHeight) {
const widthAtTargetHeight = (deviceWidth / viewportHeight) * targetHeight;
return widthAtTargetHeight >= 700 ? 'width=1000' : `height=${Math.ceil(targetHeight)}`;
}
return deviceWidth >= 700 ? 'width=1000' : 'width=device-width, initial-scale=1';
};
export const resolveViewportContent = (mode: ScreenMode, measurements: AutoViewportMeasurements): string => {
if (mode === '500px') return 'width=500';
if (mode === '1000px') return 'width=1000';
return resolveAutoViewportContent(measurements);
};
const findOrCreateViewportMeta = (): HTMLMetaElement => {
const existing = document.querySelector<HTMLMetaElement>('meta[name="viewport"]');
if (existing) return existing;
const viewportMeta = document.createElement('meta');
viewportMeta.name = 'viewport';
document.head.appendChild(viewportMeta);
return viewportMeta;
};
export const installScreenModeViewport = (targetHeight = 700): void => {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const viewportMeta = findOrCreateViewportMeta();
let previousMode: ScreenMode | null = null;
let previousDeviceWidth: number | null = null;
const adjustViewport = () => {
const mode = normalizeScreenMode(window.localStorage.getItem(SCREEN_MODE_KEY));
const deviceWidth = window.screen.availWidth;
if (mode === previousMode && mode === 'auto' && deviceWidth === previousDeviceWidth) return;
if (mode === previousMode && mode !== 'auto') return;
previousMode = mode;
previousDeviceWidth = deviceWidth;
viewportMeta.content = resolveViewportContent(mode, {
deviceWidth,
viewportHeight: window.innerHeight,
targetHeight,
});
};
adjustViewport();
window.addEventListener('resize', adjustViewport);
window.addEventListener('orientationchange', adjustViewport);
window.addEventListener('storage', (event) => {
if (event.key === SCREEN_MODE_KEY) adjustViewport();
});
document.addEventListener(SCREEN_MODE_CHANGE_EVENT, adjustViewport);
};
+18
View File
@@ -44,6 +44,7 @@ const isMobile = useMediaQuery('(max-width: 939.98px)');
const npcMode = ref(0);
const globalNavigation = ref<MainNavigationEntry[]>(defaultGlobalNavigation);
const versionDialog = ref<HTMLDialogElement | null>(null);
const buildCommitSha = import.meta.env.VITE_BUILD_COMMIT_SHA?.trim() || 'unknown';
const mobilePanelOrder = ref(loadMobileMainPanelOrder());
const navigationUrl = (import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc').replace(/\/trpc\/?$/u, '/navigation');
@@ -571,6 +572,10 @@ watch(
<h2 id="game-version-title">게임 정보</h2>
<p>{{ lobbyInfo?.scenarioTitle || 'Core2026' }}</p>
<p>삼국지 모의전투 Core2026</p>
<p class="game-version-dialog__commit">
<span>빌드 커밋</span>
<code>{{ buildCommitSha }}</code>
</p>
<form method="dialog"><button class="legacy-button legacy-button--navigation" type="submit">닫기</button></form>
</dialog>
</template>
@@ -584,6 +589,7 @@ button {
}
.game-version-dialog {
box-sizing: border-box;
width: min(420px, calc(100vw - 32px));
border: 1px solid #555;
border-radius: 4px;
@@ -607,6 +613,18 @@ button {
justify-content: center;
}
.game-version-dialog__commit {
display: flex;
flex-direction: column;
gap: 4px;
}
.game-version-dialog__commit code {
overflow-wrap: anywhere;
color: #d7d7d7;
font-size: 0.85em;
}
/*
* Ref's main document does not clip horizontally; the map panel below manages
* its own overflow.
+39 -58
View File
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import SortableStringList from '../components/ui/SortableStringList';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport';
import {
DEFAULT_MOBILE_MAIN_PANEL_ORDER,
loadMobileMainPanelOrder,
@@ -18,11 +20,9 @@ import {
type MobileMainPanelId,
} from '../utils/mobileMainPanelOrder';
const SCREEN_MODE_KEY = 'sam.screenMode';
const CUSTOM_CSS_KEY = 'sam_customCSS';
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback();
type ScreenMode = 'auto' | '500px' | '1000px';
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
@@ -58,7 +58,6 @@ const selectedIconId = ref('');
const cssSaving = ref(false);
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
const mobileLayoutDragIndex = ref<number | null>(null);
const session = useSessionStore();
let cssTimer: number | null = null;
const readPendingDieOnPrestartId = (): string => {
@@ -180,9 +179,9 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
);
const iconChoices = computed(() => data.value?.iconChoices ?? []);
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
const mobileLayoutLabels = Object.fromEntries(
const mobileLayoutLabels: Readonly<Record<string, string>> = Object.fromEntries(
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
) as Record<MobileMainPanelId, string>;
);
const openMobileLayoutDialog = () => {
mobileLayoutOrder.value = loadMobileMainPanelOrder();
@@ -194,20 +193,6 @@ const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => {
mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex);
};
const startMobileLayoutDrag = (event: DragEvent, index: number) => {
mobileLayoutDragIndex.value = index;
event.dataTransfer?.setData('text/plain', mobileLayoutOrder.value[index] ?? '');
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
};
const dropMobileLayoutItem = (event: DragEvent, targetIndex: number) => {
event.preventDefault();
const sourceIndex = mobileLayoutDragIndex.value;
mobileLayoutDragIndex.value = null;
if (sourceIndex === null) return;
moveMobileLayoutItem(sourceIndex, targetIndex);
};
const resetMobileLayoutOrder = () => {
mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER];
};
@@ -387,7 +372,7 @@ const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: strin
watch(screenMode, (mode) => {
localStorage.setItem(SCREEN_MODE_KEY, mode);
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT));
});
watch(customCss, (text) => {
@@ -697,7 +682,6 @@ onMounted(() => {
ref="mobileLayoutDialog"
class="mobile-layout-dialog"
aria-labelledby="mobile-layout-dialog-title"
@close="mobileLayoutDragIndex = null"
>
<div class="mobile-layout-dialog__header">
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
@@ -706,42 +690,39 @@ onMounted(() => {
</form>
</div>
<p>항목을 끌어 놓거나 ·아래 버튼으로 상대 순서를 바꿉니다.</p>
<ol class="mobile-layout-list">
<li
v-for="(panelId, index) in mobileLayoutOrder"
:key="panelId"
:data-mobile-layout-id="panelId"
draggable="true"
@dragstart="startMobileLayoutDrag($event, index)"
@dragend="mobileLayoutDragIndex = null"
@dragover.prevent
@drop.stop="dropMobileLayoutItem($event, index)"
>
<span class="mobile-layout-handle" aria-hidden="true"></span>
<span class="mobile-layout-label">
<span class="mobile-layout-position">{{ index + 1 }}</span>
{{ mobileLayoutLabels[panelId] }}
</span>
<span class="mobile-layout-move-buttons">
<button
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
:disabled="index === 0"
@click="moveMobileLayoutItem(index, index - 1)"
>
</button>
<button
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
:disabled="index === mobileLayoutOrder.length - 1"
@click="moveMobileLayoutItem(index, index + 1)"
>
</button>
</span>
</li>
</ol>
<SortableStringList
:list="mobileLayoutOrder"
tag="ol"
class="mobile-layout-list"
>
<template #item="{ element: panelId, index }">
<li :data-mobile-layout-id="panelId">
<span class="mobile-layout-handle" aria-hidden="true"></span>
<span class="mobile-layout-label">
<span class="mobile-layout-position">{{ index + 1 }}</span>
{{ mobileLayoutLabels[panelId] }}
</span>
<span class="mobile-layout-move-buttons">
<button
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
:disabled="index === 0"
@click="moveMobileLayoutItem(index, index - 1)"
>
</button>
<button
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
:disabled="index === mobileLayoutOrder.length - 1"
@click="moveMobileLayoutItem(index, index + 1)"
>
</button>
</span>
</li>
</template>
</SortableStringList>
<div class="mobile-layout-dialog__actions">
<button type="button" @click="resetMobileLayoutOrder">기본값</button>
<form method="dialog"><button type="submit">취소</button></form>
+44 -79
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import SortableStringList from '../components/ui/SortableStringList';
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
import { trpc } from '../utils/trpc';
@@ -8,7 +9,6 @@ type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
type PrioritySectionKey = 'nation' | 'general';
type PriorityBucket = 'active' | 'inactive';
interface PolicyField {
key: NumericPolicyKey;
@@ -35,12 +35,6 @@ interface PriorityPanel {
state: PriorityListState;
}
interface DragState {
section: PrioritySectionKey;
bucket: PriorityBucket;
index: number;
}
const loading = ref(false);
const error = ref<string | null>(null);
const notice = ref<string | null>(null);
@@ -51,7 +45,6 @@ const nationPriority = ref<PriorityListState | null>(null);
const generalPriority = ref<PriorityListState | null>(null);
const lastSavedNationPriority = ref<string[]>([]);
const lastSavedGeneralPriority = ref<string[]>([]);
const dragState = ref<DragState | null>(null);
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) return value.message;
@@ -363,26 +356,6 @@ const submitPriority = async (section: PrioritySectionKey) => {
}
};
const startDrag = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, index: number) => {
dragState.value = { section, bucket, index };
event.dataTransfer?.setData('text/plain', `${section}:${bucket}:${index}`);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
};
const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, targetIndex?: number) => {
event.preventDefault();
const source = dragState.value;
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!source || source.section !== section || !state) return;
const sourceList = state[source.bucket];
const targetList = state[bucket];
const [item] = sourceList.splice(source.index, 1);
if (!item) return;
let index = targetIndex ?? targetList.length;
if (sourceList === targetList && source.index < index) index -= 1;
targetList.splice(Math.max(0, Math.min(index, targetList.length)), 0, item);
dragState.value = null;
};
</script>
<template>
@@ -474,66 +447,58 @@ const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: Pri
<div class="priority-columns">
<div class="priority-column">
<div class="sub_bar legacy-bg2">비활성</div>
<div
<SortableStringList
:list="panel.state.inactive"
:group="`npc-priority-${panel.key}`"
tag="div"
class="priority-list"
@dragover.prevent
@drop="dropPriority($event, panel.key, 'inactive')"
>
<div class="inactive-header">&lt;비활성화 항목들&gt;</div>
<div
v-for="(item, index) in panel.state.inactive"
:key="item"
class="priority-item"
draggable="true"
@dragstart="startDrag($event, panel.key, 'inactive', index)"
@dragover.prevent
@drop.stop="dropPriority($event, panel.key, 'inactive', index)"
>
<div class="priority_info">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</button>
<template #header>
<div class="inactive-header">&lt;비활성화 항목들&gt;</div>
</template>
<template #item="{ element: item }">
<div class="priority-item">
<div class="priority_info">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</button>
</div>
</div>
</div>
</div>
</template>
</SortableStringList>
</div>
<div class="priority-column">
<div class="sub_bar legacy-bg2">활성</div>
<div
<SortableStringList
:list="panel.state.active"
:group="`npc-priority-${panel.key}`"
tag="div"
class="priority-list"
@dragover.prevent
@drop="dropPriority($event, panel.key, 'active')"
>
<div
v-for="(item, index) in panel.state.active"
:key="`${item}-${index}`"
class="priority-item"
draggable="true"
@dragstart="startDrag($event, panel.key, 'active', index)"
@dragover.prevent
@drop.stop="dropPriority($event, panel.key, 'active', index)"
>
<div class="priority_info">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</button>
<template #item="{ element: item }">
<div class="priority-item">
<div class="priority_info">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</button>
</div>
</div>
</div>
</div>
</template>
</SortableStringList>
</div>
</div>
<div class="control_bar priority-control">
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { commandCityOptions } from '../src/components/command/commandArgumentOptions.ts';
import type { CommandMapData, CommandOption } from '../src/components/command/types.ts';
const cities: CommandOption[] = [
{ value: 20, label: '적국 도시' },
{ value: 30, label: '아국 도시 둘' },
{ value: 40, label: '공백지' },
{ value: 10, label: '아국 도시 하나' },
];
const mapData: CommandMapData = {
year: 200,
month: 1,
startYear: 180,
cityList: [
[10, 8, 0, 1, 1, 1],
[20, 7, 0, 2, 2, 1],
[30, 6, 0, 1, 3, 1],
[40, 5, 0, 0, 4, 1],
],
nationList: [
[1, '아국', '#008000', 10],
[2, '적국', '#800000', 20],
],
myCity: 10,
myNation: 1,
};
void test('발령은 아국 도시를 먼저 두고 적국과 공백지를 원래 순서로 보존한다', () => {
const sorted = commandCityOptions('che_발령', cities, mapData);
assert.deepEqual(
sorted.map((option) => option.value),
[30, 10, 20, 40]
);
assert.deepEqual(
cities.map((option) => option.value),
[20, 30, 40, 10],
'공용 입력 option은 변경하지 않는다'
);
});
void test('다른 도시 대상 명령의 순서는 바꾸지 않는다', () => {
assert.deepEqual(
commandCityOptions('che_출병', cities, mapData).map((option) => option.value),
[20, 30, 40, 10]
);
});
void test('지도 국가 정보가 아직 없으면 발령의 기존 option 순서를 유지한다', () => {
assert.deepEqual(
commandCityOptions('che_발령', cities, null).map((option) => option.value),
[20, 30, 40, 10]
);
});
@@ -4,6 +4,7 @@ import test from 'node:test';
import {
commandArgumentPresentation,
presentedCommandKeys,
resolveCommandArgumentMapTarget,
} from '../src/components/command/commandArgumentPresentation.ts';
const cityCommands = [
@@ -82,3 +83,25 @@ void test('marks the same city and nation target families that Ref renders with
assert.equal(commandArgumentPresentation(commandKey).mapTarget, 'capital', commandKey);
}
});
void test('derives selection maps from the actual city and nation argument contract', () => {
assert.equal(
resolveCommandArgumentMapTarget('future_city_command', [
{ key: 'target', label: '도시', kind: 'select', required: true, optionSource: 'cities' },
]),
'city'
);
assert.equal(
resolveCommandArgumentMapTarget('future_nation_command', [
{ key: 'target', label: '국가', kind: 'select', required: true, optionSource: 'nations' },
]),
'nation'
);
assert.equal(resolveCommandArgumentMapTarget('che_증축', []), 'capital');
assert.equal(
resolveCommandArgumentMapTarget('future_general_command', [
{ key: 'target', label: '장수', kind: 'select', required: true, optionSource: 'generals' },
]),
undefined
);
});
@@ -5,6 +5,7 @@ import { describe, it } from 'node:test';
const routerSourcePath = path.resolve(import.meta.dirname, '../src/router/index.ts');
const mainDashboardSourcePath = path.resolve(import.meta.dirname, '../src/stores/mainDashboard.ts');
const myPageSourcePath = path.resolve(import.meta.dirname, '../src/views/MyPageView.vue');
void describe('game route loading contract', () => {
void it('loads view components through route-level dynamic imports', async () => {
@@ -26,4 +27,11 @@ void describe('game route loading contract', () => {
assert.doesNotMatch(source, /from\s+['"]@sammo-ts\/logic['"]/);
assert.match(source, /from\s+['"]@sammo-ts\/logic\/messages\/message\.js['"]/);
});
void it('does not preload the server-side logic barrel from the my-page route', async () => {
const source = await readFile(myPageSourcePath, 'utf8');
assert.doesNotMatch(source, /from\s+['"]@sammo-ts\/logic['"]/);
assert.match(source, /from\s+['"]@sammo-ts\/logic\/scenario\/scenarioEffect\.js['"]/);
});
});
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeScreenMode,
resolveAutoViewportContent,
resolveViewportContent,
} from '../src/utils/screenModeViewport.ts';
void test('automatic mode follows the Ref physical-screen thresholds', () => {
assert.equal(resolveAutoViewportContent({ deviceWidth: 390, viewportHeight: 844 }), 'width=500');
assert.equal(
resolveAutoViewportContent({ deviceWidth: 699, viewportHeight: 900 }),
'width=device-width, initial-scale=1'
);
assert.equal(resolveAutoViewportContent({ deviceWidth: 700, viewportHeight: 900 }), 'width=1000');
assert.equal(resolveAutoViewportContent({ deviceWidth: 820, viewportHeight: 1180 }), 'width=1000');
});
void test('automatic mode preserves the Ref short-viewport aspect-ratio branch', () => {
assert.equal(resolveAutoViewportContent({ deviceWidth: 600, viewportHeight: 650 }), 'height=700');
assert.equal(resolveAutoViewportContent({ deviceWidth: 650, viewportHeight: 600 }), 'width=1000');
});
void test('explicit modes override automatic measurements and invalid storage falls back to auto', () => {
const phone = { deviceWidth: 390, viewportHeight: 844 };
const tablet = { deviceWidth: 820, viewportHeight: 1180 };
assert.equal(resolveViewportContent('1000px', phone), 'width=1000');
assert.equal(resolveViewportContent('500px', tablet), 'width=500');
assert.equal(normalizeScreenMode('1000px'), '1000px');
assert.equal(normalizeScreenMode('500px'), '500px');
assert.equal(normalizeScreenMode('unexpected'), 'auto');
assert.equal(normalizeScreenMode(null), 'auto');
});
+25
View File
@@ -28,4 +28,29 @@ void describe('game frontend Vite config', () => {
assert.equal(loaded?.config.build?.sourcemap, true);
});
void it('uses the deployment-pinned full commit SHA as the displayed build version', async () => {
const commitSha = 'ABCDEF0123456789ABCDEF0123456789ABCDEF01';
const previousCommitSha = process.env.VITE_BUILD_COMMIT_SHA;
process.env.VITE_BUILD_COMMIT_SHA = commitSha;
try {
const configPath = path.resolve(import.meta.dirname, '../vite.config.ts');
const loaded = await loadConfigFromFile(
{ command: 'build', mode: 'production' },
configPath,
path.dirname(configPath),
undefined,
undefined,
'runner'
);
assert.equal(
loaded?.config.define?.['import.meta.env.VITE_BUILD_COMMIT_SHA'],
JSON.stringify(commitSha.toLowerCase())
);
} finally {
if (previousCommitSha === undefined) delete process.env.VITE_BUILD_COMMIT_SHA;
else process.env.VITE_BUILD_COMMIT_SHA = previousCommitSha;
}
});
});
+24
View File
@@ -1,9 +1,29 @@
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite';
import { execFileSync } from 'node:child_process';
import path from 'path';
import { mergeViteEnv } from './src/config/viteEnv';
const fullCommitShaPattern = /^[0-9a-f]{40,64}$/iu;
export const resolveBuildCommitSha = (explicitSha: string | undefined, repositoryRoot: string): string => {
const normalizedExplicitSha = explicitSha?.trim();
if (normalizedExplicitSha && fullCommitShaPattern.test(normalizedExplicitSha)) {
return normalizedExplicitSha.toLowerCase();
}
try {
const repositorySha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: repositoryRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return fullCommitShaPattern.test(repositorySha) ? repositorySha.toLowerCase() : 'unknown';
} catch {
return 'unknown';
}
};
const normalizeBasePath = (value: string | undefined): string => {
const pathValue = (value ?? '/').trim();
if (!pathValue || pathValue === '/') {
@@ -27,9 +47,13 @@ const resolvePreviewAllowedHosts = (value: string | undefined): true | string[]
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = mergeViteEnv(loadEnv(mode, process.cwd(), ''), process.env);
const buildCommitSha = resolveBuildCommitSha(env.VITE_BUILD_COMMIT_SHA, path.resolve(import.meta.dirname, '../..'));
return {
base: normalizeBasePath(env.VITE_APP_BASE_PATH),
plugins: [vue(), tailwindcss()],
define: {
'import.meta.env.VITE_BUILD_COMMIT_SHA': JSON.stringify(buildCommitSha),
},
build: {
sourcemap: true,
},
@@ -158,6 +158,20 @@ export const planProfileReconcile = (
};
};
export const resolveResetLifecycleStatus = (
now: Date,
preopenAt: Date | null,
openAt: Date | null
): Extract<GatewayProfileStatus, 'RESERVED' | 'PREOPEN' | 'RUNNING'> => {
if (preopenAt && preopenAt.getTime() > now.getTime()) {
return 'RESERVED';
}
if (openAt && openAt.getTime() > now.getTime()) {
return 'PREOPEN';
}
return 'RUNNING';
};
type GatewayAdminActionStatus = 'REQUESTED' | 'APPLIED' | 'FAILED' | 'IGNORED';
interface GatewayAdminActionRecord {
@@ -538,9 +552,13 @@ const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string):
export const buildProfileFrontendCommands = (
workspaceRoot: string,
profile: Pick<GatewayProfileRecord, 'profileName' | 'profile' | 'apiPort'>,
buildCommitSha: string,
env?: Record<string, string>,
cacheAnchorRoot: string = workspaceRoot
): BuildCommand[] => {
if (!/^[0-9a-f]{40,64}$/iu.test(buildCommitSha.trim())) {
throw new Error('Profile frontend build requires a full commit SHA.');
}
const profileFrontendBuildNodeOptions = env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS?.trim();
const buildEnv = {
...(env ?? {}),
@@ -548,6 +566,7 @@ export const buildProfileFrontendCommands = (
VITE_APP_BASE_PATH: `/${profile.profile}`,
VITE_GAME_API_URL: `/${profile.profile}/api/trpc`,
VITE_GAME_SSE_URL: `/${profile.profile}/api/events`,
VITE_BUILD_COMMIT_SHA: buildCommitSha.trim().toLowerCase(),
};
return [
buildTurboReleaseTaskCommand(
@@ -880,7 +899,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const now = this.now();
const due = await this.repository.listReservedToStart(now);
for (const profile of due) {
if (!profile.preopenAt || !profile.openAt) {
const preopenAt = parseDateTime(profile.preopenAt);
const openAt = parseDateTime(profile.openAt);
if (!preopenAt || !openAt) {
await this.repository.updateLastError(
profile.profileName,
'Reserved profile is missing preopen/open schedule.'
@@ -894,6 +915,18 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
);
continue;
}
if (profile.currentScenario !== null && profile.buildStatus === 'SUCCEEDED' && profile.buildWorkspace) {
await this.repository.updateStatus(
profile.profileName,
resolveResetLifecycleStatus(now, preopenAt, openAt),
{
preopenAt: profile.preopenAt,
openAt: profile.openAt,
}
);
await this.repository.updateLastError(profile.profileName, null);
continue;
}
const queued = profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING';
if (!queued) {
await this.repository.updateBuildStatus(profile.profileName, 'QUEUED', {
@@ -1475,6 +1508,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
...buildProfileFrontendCommands(
workspace.root,
profile,
commitSha,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
),
@@ -1884,8 +1918,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await assertLease?.();
const completedAt = this.now().toISOString();
const now = this.now();
const shouldPreopen = openAt ? openAt.getTime() > now.getTime() : false;
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, openAt);
const publishedProfile = await updateClaimedProfile(
{
currentScenario: String(scenarioId),
@@ -1917,30 +1950,37 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
);
releasePrepared = true;
const builtProfile = publishedProfile ?? {
...profile,
currentScenario: String(scenarioId),
scenario: String(scenarioId),
status: desiredStatus,
buildWorkspace: workspace.root,
};
await appendLog('switch', '초기화된 profile process를 시작합니다.');
const started = await this.startProfile(builtProfile, assertLease);
await appendLog('readiness', 'profile process readiness를 확인합니다.');
const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease));
if (!ready) {
if (started) {
await this.stopProfile(builtProfile, assertLease);
}
const detail = started
? 'reset completed but profile processes failed readiness'
: 'reset completed but profile processes failed to start';
await updateClaimedProfile({ status: 'STOPPED', lastError: detail }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED')
if (desiredStatus === 'RESERVED') {
await appendLog(
'schedule',
`${preopenAt?.toISOString() ?? '가오픈 시각'}까지 RESERVED 상태로 접속을 차단합니다.`
);
return { status: 'FAILED', detail };
} else {
const builtProfile = publishedProfile ?? {
...profile,
currentScenario: String(scenarioId),
scenario: String(scenarioId),
status: desiredStatus,
buildWorkspace: workspace.root,
};
await appendLog('switch', '초기화된 profile process를 시작합니다.');
const started = await this.startProfile(builtProfile, assertLease);
await appendLog('readiness', 'profile process readiness를 확인합니다.');
const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease));
if (!ready) {
if (started) {
await this.stopProfile(builtProfile, assertLease);
}
const detail = started
? 'reset completed but profile processes failed readiness'
: 'reset completed but profile processes failed to start';
await updateClaimedProfile({ status: 'STOPPED', lastError: detail }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED')
);
return { status: 'FAILED', detail };
}
await appendLog('readiness', 'profile readiness 확인을 통과했습니다.');
}
await appendLog('readiness', 'profile readiness 확인을 통과했습니다.');
await updateClaimedProfile({ lastError: null }, async () => {
await this.repository.updateLastError(profile.profileName, null);
return this.repository.getProfile(profile.profileName);
@@ -2056,6 +2096,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
? buildProfileFrontendCommands(
workspace.root,
profile,
commitSha,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
)
@@ -700,6 +700,63 @@ describe('admin operation API', () => {
});
});
it('keeps reset start, preopen, and formal open as an ordered lifecycle', async () => {
const harness = await buildCaller(async (input) => ({
id: '77777777-7777-4777-8777-777777777777',
profileName: input.profileName,
type: 'RESET',
status: 'QUEUED',
sourceMode: input.sourceMode,
sourceRef: input.sourceRef,
payload: input.payload ?? {},
requestedBy: input.requestedBy,
scheduledAt: input.scheduledAt,
createdAt: '2026-08-08T00:00:00.000Z',
updatedAt: '2026-08-08T00:00:00.000Z',
}));
const install = {
scenarioId: 1010,
turnTermMinutes: 60,
sync: false,
fiction: 1 as const,
extend: false,
blockGeneralCreate: 0 as const,
npcMode: 0 as const,
showImgLevel: 0 as const,
tournamentTrig: false,
joinMode: 'full' as const,
preopenAt: '2099-01-01T01:00:00.000Z',
openAt: '2099-01-01T02:00:00.000Z',
};
await harness.caller.admin.operations.requestReset({
profileName: 'che:2',
sourceMode: 'COMMIT',
sourceRef: 'HEAD',
scheduledAt: '2099-01-01T00:00:00.000Z',
install,
});
expect(harness.createdInputs[0]).toMatchObject({
type: 'RESET',
scheduledAt: '2099-01-01T00:00:00.000Z',
payload: { install },
});
await expect(
harness.caller.admin.operations.requestReset({
profileName: 'che:2',
sourceMode: 'COMMIT',
sourceRef: 'HEAD',
scheduledAt: '2099-01-01T01:30:00.000Z',
install,
})
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: 'preopenAt cannot be earlier than scheduledAt.',
});
});
it('returns validated profile reset defaults to a scenario-only operator', async () => {
const harness = await buildCaller(
async () => {
@@ -48,6 +48,9 @@ const createHarness = (
startGate?: Promise<void>,
options: {
profile?: GatewayProfileRecord;
profiles?: GatewayProfileRecord[];
reservedToStart?: GatewayProfileRecord[];
now?: () => Date;
cancelGame?: GatewayOrchestratorOptions['cancelGame'];
} = {}
) => {
@@ -59,10 +62,11 @@ const createHarness = (
const started: ProcessDefinition[] = [];
const stopped: string[] = [];
const deleted: string[] = [];
const buildStatuses: string[] = [];
const logs: Array<{ phase: string; message: string; level: string }> = [];
const repository: GatewayProfileRepository = {
listProfiles: async () => [harnessProfile],
listProfiles: async () => options.profiles ?? [harnessProfile],
getProfile: async () => harnessProfile,
upsertProfile: async () => harnessProfile,
updateCurrentScenario: async () => harnessProfile,
@@ -70,9 +74,12 @@ const createHarness = (
statuses.push(status);
return { ...harnessProfile, status };
},
updateBuildStatus: async () => harnessProfile,
updateBuildStatus: async (_profileName, status) => {
buildStatuses.push(status);
return { ...harnessProfile, buildStatus: status };
},
updateMeta: async () => harnessProfile,
listReservedToStart: async () => [],
listReservedToStart: async () => options.reservedToStart ?? [],
findQueuedBuild: async () => null,
updateLastError: async () => {},
updateWorkspaceUsage: async () => {},
@@ -167,10 +174,11 @@ const createHarness = (
scheduleIntervalMs: 60_000,
buildIntervalMs: 60_000,
adminActionIntervalMs: 60_000,
now: options.now,
cancelGame: options.cancelGame,
});
return { orchestrator, statuses, completions, completionFields, started, stopped, deleted, logs };
return { orchestrator, statuses, buildStatuses, completions, completionFields, started, stopped, deleted, logs };
};
describe('GatewayOrchestrator first-class operations', () => {
@@ -267,6 +275,81 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.deleted).toEqual([]);
});
it('opens a prepared reserved profile without rebuilding it again', async () => {
const now = new Date('2030-01-01T01:00:00.000Z');
const reservedProfile: GatewayProfileRecord = {
...profile,
status: 'RESERVED',
currentScenario: '1010',
scenario: '1010',
buildStatus: 'SUCCEEDED',
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef',
preopenAt: now.toISOString(),
openAt: '2030-01-01T02:00:00.000Z',
};
const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, {
profile: reservedProfile,
profiles: [],
reservedToStart: [reservedProfile],
now: () => now,
});
await harness.orchestrator.runScheduleNow();
expect(harness.statuses).toEqual(['PREOPEN']);
expect(harness.buildStatuses).toEqual([]);
});
it('starts turns when a prepared reserved profile is handled after formal open', async () => {
const now = new Date('2030-01-01T02:00:00.000Z');
const reservedProfile: GatewayProfileRecord = {
...profile,
status: 'RESERVED',
currentScenario: '1010',
scenario: '1010',
buildStatus: 'SUCCEEDED',
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef',
preopenAt: '2030-01-01T01:00:00.000Z',
openAt: now.toISOString(),
};
const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, {
profile: reservedProfile,
profiles: [],
reservedToStart: [reservedProfile],
now: () => now,
});
await harness.orchestrator.runScheduleNow();
expect(harness.statuses).toEqual(['RUNNING']);
expect(harness.buildStatuses).toEqual([]);
});
it('retains the legacy build queue for an unprepared reserved profile', async () => {
const now = new Date('2030-01-01T01:00:00.000Z');
const reservedProfile: GatewayProfileRecord = {
...profile,
status: 'RESERVED',
currentScenario: null,
scenario: 'default',
buildStatus: 'IDLE',
buildWorkspace: undefined,
preopenAt: now.toISOString(),
openAt: '2030-01-01T02:00:00.000Z',
};
const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, {
profile: reservedProfile,
profiles: [],
reservedToStart: [reservedProfile],
now: () => now,
});
await harness.orchestrator.runScheduleNow();
expect(harness.statuses).toEqual([]);
expect(harness.buildStatuses).toEqual(['QUEUED']);
});
it('starts every profile process and records success', async () => {
const harness = createHarness(buildOperation('START'));
+35 -2
View File
@@ -8,6 +8,7 @@ import {
buildProcessDefinitions,
buildWorkspaceCommands,
planProfileReconcile,
resolveResetLifecycleStatus,
} from '../src/orchestrator/gatewayOrchestrator.js';
import { sanitizeManagedProcessEnv } from '../src/orchestrator/processManager.js';
import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js';
@@ -108,6 +109,29 @@ describe('planProfileReconcile', () => {
});
});
describe('resolveResetLifecycleStatus', () => {
const now = new Date('2030-01-01T00:00:00.000Z');
it('keeps an initialized profile reserved until the configured preopen time', () => {
expect(
resolveResetLifecycleStatus(now, new Date('2030-01-01T01:00:00.000Z'), new Date('2030-01-01T02:00:00.000Z'))
).toBe('RESERVED');
});
it('moves through preopen before the formal open time', () => {
expect(
resolveResetLifecycleStatus(now, new Date('2029-12-31T23:00:00.000Z'), new Date('2030-01-01T02:00:00.000Z'))
).toBe('PREOPEN');
});
it('runs immediately when no future lifecycle boundary remains', () => {
expect(resolveResetLifecycleStatus(now, null, null)).toBe('RUNNING');
expect(
resolveResetLifecycleStatus(now, new Date('2029-12-31T22:00:00.000Z'), new Date('2029-12-31T23:00:00.000Z'))
).toBe('RUNNING');
});
});
describe('buildProcessDefinitions', () => {
const processConfig = {
workspaceRoot: '/srv/sammo/main',
@@ -347,9 +371,11 @@ describe('buildWorkspaceCommands', () => {
});
describe('buildProfileFrontendCommands', () => {
const buildCommitSha = '0123456789abcdef0123456789abcdef01234567';
it('uses a profile frontend build-only Node heap without changing the shared runtime heap', () => {
const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef';
const commands = buildProfileFrontendCommands(workspaceRoot, buildProfile(), {
const commands = buildProfileFrontendCommands(workspaceRoot, buildProfile(), buildCommitSha, {
NODE_OPTIONS: '--max-old-space-size=1536',
PROFILE_FRONTEND_BUILD_NODE_OPTIONS: '--max-old-space-size=2048',
});
@@ -361,6 +387,7 @@ describe('buildProfileFrontendCommands', () => {
(command) => command.env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS === '--max-old-space-size=2048'
)
).toBe(true);
expect(commands.every((command) => command.env?.VITE_BUILD_COMMIT_SHA === buildCommitSha)).toBe(true);
expect(commands[0]?.args).toEqual([
'exec',
'turbo',
@@ -377,10 +404,16 @@ describe('buildProfileFrontendCommands', () => {
it('keeps the shared Node heap when no frontend build override is configured', () => {
const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef';
const commands = buildProfileFrontendCommands(workspaceRoot, buildProfile(), {
const commands = buildProfileFrontendCommands(workspaceRoot, buildProfile(), buildCommitSha, {
NODE_OPTIONS: '--max-old-space-size=1536',
});
expect(commands.every((command) => command.env?.NODE_OPTIONS === '--max-old-space-size=1536')).toBe(true);
});
it('rejects a non-commit build version before creating cached frontend commands', () => {
expect(() => buildProfileFrontendCommands('/srv/sammo/worktrees/main', buildProfile(), 'main')).toThrow(
'Profile frontend build requires a full commit SHA.'
);
});
});
@@ -194,6 +194,8 @@ describe('profile DEPLOY operation', () => {
'tools/build-scripts/materialize-profile-frontend.mjs',
'che:1010',
]);
expect(commandGroups[0]?.[2]?.env?.VITE_BUILD_COMMIT_SHA).toBe(SHA);
expect(commandGroups[0]?.[3]?.env?.VITE_BUILD_COMMIT_SHA).toBe(SHA);
expect(commandGroups[1]?.map((command) => command.args)).toEqual([
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
]);
@@ -126,6 +126,19 @@ test('bootstrap superuser can navigate the administrator workspace from the lobb
await expect(page).toHaveURL(/\/gateway\/admin$/);
await expect(page.getByRole('heading', { name: '운영 개요' })).toBeVisible();
await expect(page.getByText('관리 대상을 선택하세요.')).toBeVisible();
await expect(page.getByRole('region', { name: '관리 원칙' })).toHaveCount(0);
await expect(page.getByText('작업 영역이 분리되었습니다.')).toHaveCount(0);
const overviewGrid = page.getByRole('region', { name: '관리 기능' });
const desktopOverviewGeometry = await overviewGrid.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, top: rect.top, width: rect.width };
});
expect(desktopOverviewGeometry.top).toBeLessThan(300);
await writeFile(
testInfo.outputPath('admin-overview-desktop-geometry.json'),
JSON.stringify(desktopOverviewGeometry)
);
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' });
await expect(navigation).toBeVisible();
const userLink = navigation.getByRole('link', { name: '사용자 관리' });
@@ -148,6 +161,24 @@ test('bootstrap superuser can navigate the administrator workspace from the lobb
await writeFile(testInfo.outputPath('admin-overview-mobile-geometry.json'), JSON.stringify(geometry));
await page.screenshot({ path: testInfo.outputPath('admin-overview-mobile-menu.png'), fullPage: true });
await page.getByRole('button', { name: '관리자 메뉴' }).click();
await expect(navigation).toBeHidden();
await page.evaluate(() => window.scrollTo(0, 0));
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0);
const mobileOverviewGeometry = await overviewGrid.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, top: rect.top, width: rect.width };
});
expect(mobileOverviewGeometry.left).toBeGreaterThanOrEqual(0);
expect(mobileOverviewGeometry.right).toBeLessThanOrEqual(390);
expect(mobileOverviewGeometry.top).toBeLessThan(340);
await writeFile(
testInfo.outputPath('admin-overview-mobile-content-geometry.json'),
JSON.stringify(mobileOverviewGeometry)
);
await page.screenshot({ path: testInfo.outputPath('admin-overview-mobile-content.png'), fullPage: true });
await page.getByRole('button', { name: '관리자 메뉴' }).click();
await navigation.getByRole('link', { name: 'Gateway 릴리스' }).click();
await expect(page).toHaveURL(/\/gateway\/admin\/releases$/);
await expect(page.getByRole('heading', { name: 'Gateway 릴리스', level: 1 })).toBeVisible();
@@ -600,7 +600,15 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
await page.getByTestId('load-scenarios').click();
await page.getByTestId('scenario-select').selectOption('5');
await page.getByLabel('작업 예약 (서버 시간 UTC+9)').fill('2026-08-13T09:30');
await expect(page.getByText('초기화 시작 → 가오픈 시작 → 정식 오픈 순서입니다.')).toBeVisible();
await page.getByTestId('reset-scheduled-at').fill('2030-08-13T09:30');
await page.getByTestId('reset-preopen-at').fill('2030-08-13T10:00');
await page.getByTestId('reset-open-at').fill('2030-08-13T11:00');
const scheduledHelp = page.getByTestId('reset-help-scheduled-at');
await scheduledHelp.hover();
await expect(page.getByTestId('reset-help-scheduled-at-tooltip')).toContainText(
'완료되어도 가오픈 전에는 접속을 차단합니다.'
);
await page.getByTestId('request-reset').hover();
await page.getByTestId('request-reset').click();
@@ -655,7 +663,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2026-08-13T00:30:00.000Z"');
expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2030-08-13T00:30:00.000Z"');
expect(JSON.stringify(resetRequest?.body)).toContain('"preopenAt":"2030-08-13T01:00:00.000Z"');
expect(JSON.stringify(resetRequest?.body)).toContain('"openAt":"2030-08-13T02:00:00.000Z"');
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
@@ -1046,7 +1056,7 @@ test('uses ref reset terms with compact hover, focus, and mobile help', async ({
]);
const helpButtons = page.getByRole('button', { name: /도움말$/ });
await expect(helpButtons).toHaveCount(10);
await expect(helpButtons).toHaveCount(13);
const fictionHelp = page.getByTestId('reset-help-fiction');
const fictionTooltip = page.getByTestId('reset-help-fiction-tooltip');
await expect(fictionTooltip).toBeHidden();
@@ -77,19 +77,7 @@ onMounted(async () => {
</script>
<template>
<AdminConsoleLayout
title="운영 개요"
description="관리 대상을 먼저 선택하세요. 위험도가 높은 변경과 배포 이력은 각 영역에서 분리해 확인할 수 있습니다."
eyebrow="Admin workspace"
>
<section class="overview-guide" aria-label="관리 원칙">
<div>
<strong>작업 영역이 분리되었습니다.</strong>
<p>계정 변경, 서버 설정, 버전 배포가 화면에 섞이지 않도록 책임별로 나누었습니다.</p>
</div>
<span>권한 검사는 기존 서버 정책을 그대로 따릅니다.</span>
</section>
<AdminConsoleLayout title="운영 개요" description="관리 대상을 선택하세요." eyebrow="Admin workspace">
<section class="overview-grid" aria-label="관리 기능">
<RouterLink
v-for="section in sections.filter((entry) => entry.visible)"
@@ -108,37 +96,6 @@ onMounted(async () => {
</template>
<style scoped>
.overview-guide {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 22px;
border: 1px solid #3f3f46;
border-radius: 10px;
background: #18181b;
padding: 18px 20px;
}
.overview-guide strong {
color: #f4f4f5;
font-size: 14px;
}
.overview-guide p,
.overview-guide span {
margin: 4px 0 0;
color: #a1a1aa;
font-size: 12px;
line-height: 1.5;
}
.overview-guide > span {
flex: 0 0 auto;
margin: 0;
color: #fbbf24;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -214,11 +171,6 @@ onMounted(async () => {
}
@media (max-width: 700px) {
.overview-guide {
align-items: flex-start;
flex-direction: column;
}
.overview-grid {
grid-template-columns: 1fr;
}
@@ -162,6 +162,20 @@ const RESET_AUTORUN_FORM_KEYS = {
battle: 'autorunBattle',
chief: 'autorunChief',
} as const satisfies Record<ResetAutorunOption, keyof typeof form>;
const RESET_SCHEDULE_COPY = {
scheduledAt: {
label: '초기화 시작',
help: 'Gateway가 빌드, DB 초기화와 시나리오 생성을 시작합니다. 비우면 즉시 시작하며, 완료되어도 가오픈 전에는 접속을 차단합니다.',
},
preopenAt: {
label: '가오픈 시작',
help: '게임 접속과 장수 생성, 예약턴 입력을 허용하지만 턴은 진행하지 않습니다. 가오픈을 비우고 정식 오픈만 지정하면 초기화 완료 후 바로 가오픈합니다.',
},
openAt: {
label: '정식 오픈',
help: '턴 진행을 시작합니다. 비우면 초기화가 완료되는 즉시 정식 오픈합니다.',
},
} as const;
const gatewayForm = reactive({
sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT',
sourceRef: 'main',
@@ -1313,31 +1327,66 @@ onBeforeUnmount(() => {
</div>
</details>
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3">
<label class="text-xs text-zinc-400"
>작업 예약 (서버 시간 UTC+9)
<input
v-model="form.scheduledAt"
type="datetime-local"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
/>
</label>
<label class="text-xs text-zinc-400"
>가오픈 (서버 시간 UTC+9)
<input
v-model="form.preopenAt"
type="datetime-local"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
/>
</label>
<label class="text-xs text-zinc-400"
>정식 오픈 (서버 시간 UTC+9)
<input
v-model="form.openAt"
type="datetime-local"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
/>
</label>
<div v-if="mode === 'scenario'" class="space-y-2 rounded border border-zinc-800 p-3">
<p class="text-xs leading-5 text-zinc-400">
초기화 시작 가오픈 시작 정식 오픈 순서입니다. 초기화 시작을 비우면 바로 작업합니다.
</p>
<div class="grid gap-4 md:grid-cols-3">
<div class="space-y-1">
<div class="flex items-center gap-1.5 text-xs text-zinc-400">
<label for="reset-scheduled-at">{{ RESET_SCHEDULE_COPY.scheduledAt.label }}</label>
<CompactHelp
:label="RESET_SCHEDULE_COPY.scheduledAt.label"
:text="RESET_SCHEDULE_COPY.scheduledAt.help"
test-id="reset-help-scheduled-at"
/>
<span>(선택 · UTC+9)</span>
</div>
<input
id="reset-scheduled-at"
v-model="form.scheduledAt"
type="datetime-local"
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
data-testid="reset-scheduled-at"
/>
</div>
<div class="space-y-1">
<div class="flex items-center gap-1.5 text-xs text-zinc-400">
<label for="reset-preopen-at">{{ RESET_SCHEDULE_COPY.preopenAt.label }}</label>
<CompactHelp
:label="RESET_SCHEDULE_COPY.preopenAt.label"
:text="RESET_SCHEDULE_COPY.preopenAt.help"
test-id="reset-help-preopen-at"
/>
<span>(선택 · UTC+9)</span>
</div>
<input
id="reset-preopen-at"
v-model="form.preopenAt"
type="datetime-local"
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
data-testid="reset-preopen-at"
/>
</div>
<div class="space-y-1">
<div class="flex items-center gap-1.5 text-xs text-zinc-400">
<label for="reset-open-at">{{ RESET_SCHEDULE_COPY.openAt.label }}</label>
<CompactHelp
:label="RESET_SCHEDULE_COPY.openAt.label"
:text="RESET_SCHEDULE_COPY.openAt.help"
test-id="reset-help-open-at"
/>
<span>(선택 · UTC+9)</span>
</div>
<input
id="reset-open-at"
v-model="form.openAt"
type="datetime-local"
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
data-testid="reset-open-at"
/>
</div>
</div>
</div>
<input