merge: 최신 main을 고급 턴 모바일 드래그 브랜치에 통합
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
@@ -1125,9 +1125,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분');
|
||||
@@ -1279,7 +1277,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,
|
||||
@@ -2794,6 +2794,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 });
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc
|
||||
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,
|
||||
@@ -19,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>>;
|
||||
@@ -373,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) => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 {
|
||||
@@ -880,7 +894,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 +910,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', {
|
||||
@@ -1884,8 +1912,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 +1944,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);
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,6 +58,26 @@
|
||||
시나리오 80개 중 70개가 확장을 사용합니다. 구매 가능한 전특·유니크 표를
|
||||
사용하는 10개 시나리오는 같은 item 확장을 참조합니다.
|
||||
|
||||
## 적용 범위와 Ref 차이
|
||||
|
||||
Ref에는 설치 시 선택한 시나리오에 별도 기능 팩을 덧붙이는 전역 애드온 단계가
|
||||
없습니다. 각 `scenario_*.json`이 `const.allItems`와
|
||||
`const.availableSpecialWar`를 직접 소유하고, `Scenario::buildConf()`가 그 값을
|
||||
`GameConst`에 반영합니다.
|
||||
|
||||
Core의 `extends`는 이 중복 값을 소스에서 재사용하기 위한 합성 기능입니다.
|
||||
설치 시 임의의 시나리오에 전역으로 적용되는 옵션이 아니며, 해당
|
||||
`scenario_*.json`이 확장을 명시한 경우에만 로더와 Gateway 미리보기가 합성합니다.
|
||||
따라서 일반 공백지 시나리오에는
|
||||
`extensions/items/buyable-war-special-uniques.json`이 암묵적으로 적용되지 않습니다.
|
||||
|
||||
공백지 중 `scenario_902`(천지비급), `scenario_910`(거울세계),
|
||||
`scenario_912`(다병종), `scenario_913`(무한대흥)은 Ref 자체가 전투 특기 아이템
|
||||
풀을 직접 정의합니다. 이 네 시나리오는 최신 공통 확장과 항목 또는 유니크 수량이
|
||||
서로 달라 직접 정의를 유지합니다. 특히 902·912는 `견고`가 없는 19종, 910은
|
||||
`척사`가 없는 19종이며, 913은 20종이지만 일부 유니크 수량이 공통 확장의 2개가
|
||||
아닌 4개입니다. 이를 공통 확장으로 바꾸면 Ref 설치 결과가 달라집니다.
|
||||
|
||||
## 검증
|
||||
|
||||
확장 파일을 추가하거나 합성 순서를 바꾼 뒤 다음 검사를 실행해 주세요.
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { canonicalFrontendFixture as fixture } from './fixtures/canonical.js';
|
||||
|
||||
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
|
||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, message: string) => ({
|
||||
error: {
|
||||
@@ -42,7 +45,35 @@ const general = {
|
||||
const generalContext = {
|
||||
general,
|
||||
city: null,
|
||||
nation: null,
|
||||
nation: {
|
||||
id: 1,
|
||||
name: '테스트국',
|
||||
color: '#d32f2f',
|
||||
level: 1,
|
||||
levelName: '군벌',
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
tech: 1000,
|
||||
typeCode: 'test',
|
||||
typeName: '테스트',
|
||||
typePros: '-',
|
||||
typeCons: '-',
|
||||
capitalCityId: 1,
|
||||
capitalCityName: '낙양',
|
||||
population: { cityCount: 1, current: 10000, max: 20000 },
|
||||
crew: { generalCount: 2, current: 1000, max: 16000 },
|
||||
power: 100,
|
||||
bill: 10,
|
||||
taxRate: 10,
|
||||
strategicCommandLimit: 0,
|
||||
diplomaticLimit: 0,
|
||||
prohibitScout: false,
|
||||
prohibitWar: false,
|
||||
techLevel: 1,
|
||||
techLimited: false,
|
||||
topChiefs: { 12: null, 11: null },
|
||||
impossibleStrategicCommands: [],
|
||||
},
|
||||
settings: {},
|
||||
penalties: {},
|
||||
};
|
||||
@@ -373,14 +404,25 @@ for (const viewport of [
|
||||
boxShadow: getComputedStyle(element).boxShadow,
|
||||
}))
|
||||
).toEqual({ outlineWidth: '0px', boxShadow: 'none' });
|
||||
|
||||
if (artifactRoot) {
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
await page.locator('.MessagePanel').screenshot({
|
||||
path: resolve(artifactRoot, `message-panel-${viewport.width}.png`),
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('exposes ambassador targets, reply, read, delete, and successful send interactions', async ({ page }) => {
|
||||
test('exposes nation targets including wanderers, reply, read, delete, and successful send interactions', async ({
|
||||
page,
|
||||
}) => {
|
||||
const mutations = await installFixture(page, { permission: 4 });
|
||||
await openMessages(page, { width: 500, height: 900 });
|
||||
|
||||
const select = page.getByLabel('메시지 수신 대상');
|
||||
await expect(select.locator('optgroup[label="외교메시지"] option[value="9000"]')).toHaveText('재야');
|
||||
await expect(select.locator('option[value="9002"]')).toHaveCount(1);
|
||||
await expect(select.locator('option[value="8"]')).toBeDisabled();
|
||||
await expect(select.locator('option[value="9"]')).toBeEnabled();
|
||||
@@ -396,11 +438,21 @@ test('exposes ambassador targets, reply, read, delete, and successful send inter
|
||||
await deleteButton.click();
|
||||
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1);
|
||||
|
||||
await select.selectOption('9999');
|
||||
await page.getByLabel('메시지 입력').fill('전송 성공');
|
||||
await select.selectOption('9000');
|
||||
await page.getByLabel('메시지 입력').fill('우리 나라로 와주세요');
|
||||
if (artifactRoot) {
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
await page.locator('.MessageInputForm').screenshot({
|
||||
path: resolve(artifactRoot, 'wanderer-recruitment-target-500.png'),
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
await page.getByRole('button', { name: '서신전달&갱신' }).click();
|
||||
await expect(page.getByLabel('메시지 입력')).toHaveValue('');
|
||||
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
|
||||
expect(JSON.stringify(mutations.find((entry) => entry.operation === 'messages.send')?.body)).toContain(
|
||||
'"mailbox":9000'
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts recruitment and declines invader prompts through private-message controls', async ({ page }) => {
|
||||
@@ -442,6 +494,7 @@ test('redacts diplomacy for a low-permission general and preserves the failed-se
|
||||
await openMessages(page, { width: 500, height: 900 });
|
||||
|
||||
const select = page.getByLabel('메시지 수신 대상');
|
||||
await expect(select.locator('option[value="9000"]')).toHaveCount(0);
|
||||
await expect(select.locator('option[value="9002"]')).toHaveCount(0);
|
||||
await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다');
|
||||
await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문');
|
||||
|
||||
@@ -65,6 +65,14 @@ const measure = async (browser, name, viewport) => {
|
||||
await ensureGeneral(page);
|
||||
await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await page.locator('.BoardHeader').first().waitFor({ state: 'visible' });
|
||||
const mailboxOptions = await page.locator('.MessageInputForm select option').evaluateAll((options) =>
|
||||
options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.textContent?.trim() ?? '',
|
||||
group: option.parentElement?.tagName === 'OPTGROUP' ? option.parentElement.label : '',
|
||||
disabled: option.disabled,
|
||||
}))
|
||||
);
|
||||
const marker = `computed-dom-${name}-${Date.now()}`;
|
||||
await page.locator('.MessageInputForm select').selectOption('9999');
|
||||
await page.locator('.MessageInputForm input').fill(marker);
|
||||
@@ -160,7 +168,11 @@ const measure = async (browser, name, viewport) => {
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await deleteButton.click();
|
||||
}
|
||||
return { ...result, interaction: { hover, focus } };
|
||||
return {
|
||||
...result,
|
||||
mailboxOptions,
|
||||
interaction: { hover, focus },
|
||||
};
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user