merge: 최신 main을 프런트엔드 import 경계에 통합

This commit is contained in:
2026-08-21 01:46:40 +00:00
15 changed files with 316 additions and 5 deletions
@@ -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 });
+55 -1
View File
@@ -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`);
});
+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: '.',
@@ -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>
+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 {
+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.
+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,
},
@@ -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
)
+11 -2
View File
@@ -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'],
]);
+6
View File
@@ -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`,
과거 장수·국가와 상속·진단 자료가 포함되지 않습니다.
+20
View File
@@ -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 설치 결과가 달라집니다.
## 검증
확장 파일을 추가하거나 합성 순서를 바꾼 뒤 다음 검사를 실행해 주세요.