merge: 최신 main을 프런트엔드 import 경계에 통합
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { 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';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const imageRoots = [
|
||||
@@ -2302,6 +2303,62 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
|
||||
await page.screenshot({ path: test.info().outputPath('advanced-command-editor.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('physical mobile touch drag selects general and nation turns in advanced mode', 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 install(mobilePage);
|
||||
await mobilePage.goto(configuredBaseUrl);
|
||||
|
||||
const editor = mobilePage.locator('[data-command-scope="general"]');
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(editor.locator('.date-column.drag-select')).toHaveCSS('touch-action', 'auto');
|
||||
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
|
||||
await expect(editor.locator('.index-column.drag-select')).toHaveCSS('touch-action', 'none');
|
||||
const cells = editor.locator('.index-column > button');
|
||||
await touchDrag(mobilePage, cells.nth(0), cells.nth(2), { targetYRatio: 0.9 });
|
||||
|
||||
await expect(editor.locator('.index-column > button.selected')).toHaveCount(3);
|
||||
const dates = editor.locator('.date-column > div');
|
||||
await touchDrag(mobilePage, dates.nth(4), dates.nth(6), { targetYRatio: 0.9 });
|
||||
await expect
|
||||
.poll(() => editor.locator('.index-column > button.selected').allTextContents())
|
||||
.toEqual(['5', '6', '7']);
|
||||
await mobilePage.screenshot({
|
||||
path: testInfo.outputPath('advanced-general-command-editor-mobile-touch.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await mobilePage.goto(new URL('chief-center', configuredBaseUrl).href);
|
||||
const chiefEditor = mobilePage.locator('[data-command-scope="nation"]:visible');
|
||||
await expect(chiefEditor).toBeVisible();
|
||||
await expect(chiefEditor.locator('.date-column.drag-select')).toHaveCSS('touch-action', 'auto');
|
||||
await chiefEditor.getByRole('button', { name: '고급 모드', exact: true }).click();
|
||||
await expect(chiefEditor.locator('.index-column.drag-select')).toHaveCSS('touch-action', 'none');
|
||||
const chiefCells = chiefEditor.locator('.index-column > button');
|
||||
await touchDrag(mobilePage, chiefCells.nth(0), chiefCells.nth(2), { targetYRatio: 0.9 });
|
||||
await expect(chiefEditor.locator('.index-column > button.selected')).toHaveCount(3);
|
||||
await mobilePage.screenshot({
|
||||
path: testInfo.outputPath('advanced-nation-command-editor-mobile-touch.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the shared main and chief shell geometry and interaction states', async ({ page }) => {
|
||||
const requests = await install(page);
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
|
||||
@@ -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,
|
||||
@@ -1250,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();
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
|
||||
@@ -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: '.',
|
||||
|
||||
@@ -80,7 +80,9 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="drag-select"><slot :selected="preview" /></div>
|
||||
<div ref="root" class="drag-select" :style="{ touchAction: props.disabled ? undefined : 'none' }">
|
||||
<slot :selected="preview" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Vendored
+1
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -552,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 ?? {}),
|
||||
@@ -562,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(
|
||||
@@ -1503,6 +1508,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
...buildProfileFrontendCommands(
|
||||
workspace.root,
|
||||
profile,
|
||||
commitSha,
|
||||
this.processConfig.baseEnv,
|
||||
this.processConfig.workspaceRoot
|
||||
),
|
||||
@@ -2090,6 +2096,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
? buildProfileFrontendCommands(
|
||||
workspace.root,
|
||||
profile,
|
||||
commitSha,
|
||||
this.processConfig.baseEnv,
|
||||
this.processConfig.workspaceRoot
|
||||
)
|
||||
|
||||
@@ -371,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',
|
||||
});
|
||||
@@ -385,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',
|
||||
@@ -401,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'],
|
||||
]);
|
||||
|
||||
@@ -125,6 +125,12 @@ commit의 game API, engine과 profile 전용 frontend artifact를 빌드합니
|
||||
프로세스를 멈춘 뒤 `prisma migrate deploy`만 실행하고 seed는 호출하지 않습니다.
|
||||
새 API·frontend와 모든 worker가 PM2 `online`이고 HTTP readiness가 성공해야
|
||||
build commit을 게시합니다. 실패하면 이전 worktree 프로세스를 다시 시작합니다.
|
||||
Profile frontend build에는 같은 전체 commit SHA를 `VITE_BUILD_COMMIT_SHA`로
|
||||
주입합니다. 이 값은 Turbo의 `VITE_*` cache key에 포함되고 Vite가 bundle 상수로
|
||||
고정하므로, 게임의 `게임 정보` dialog가 실제 선택 build commit을 표시하며 다른
|
||||
commit의 cached artifact를 현재 버전으로 오인하지 않습니다. Orchestrator 밖의
|
||||
개발 build는 현재 Git checkout의 `HEAD`를 fallback으로 사용하고 Git metadata를
|
||||
읽을 수 없을 때만 `unknown`을 표시합니다.
|
||||
`RESET` operation은 같은 build 경계를 사용한 뒤 현재 시즌 테이블을 seed로
|
||||
교체합니다. Seeder의 reset 목록에는 `hall`, `ng_games`, `yearbook_history`,
|
||||
과거 장수·국가와 상속·진단 자료가 포함되지 않습니다.
|
||||
|
||||
@@ -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 설치 결과가 달라집니다.
|
||||
|
||||
## 검증
|
||||
|
||||
확장 파일을 추가하거나 합성 순서를 바꾼 뒤 다음 검사를 실행해 주세요.
|
||||
|
||||
Reference in New Issue
Block a user