merge: 최신 로컬 main을 건국 색상 라벨 브랜치에 통합

This commit is contained in:
2026-08-19 11:33:24 +00:00
22 changed files with 1342 additions and 1897 deletions
+5 -2
View File
@@ -81,8 +81,11 @@ input-event 결과를 transaction으로 반영합니다. Redis pub/sub과 SSE는
- Vitest, Playwright/Chromium - Vitest, Playwright/Chromium
- VitePress - VitePress
Node.js 버전은 저장소에서 고정하지 않습니다. 의존성 설치와 검증에는 Node.js`.nvmrc`에서 24.x로 고정합니다. 의존성 설치와 검증에는
`package.json``pnpm-lock.yaml`을 함께 사용해 주세요. `package.json``pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는
내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는
full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로
식별합니다.
## 개발 환경 ## 개발 환경
+2 -2
View File
@@ -32,7 +32,7 @@
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^10.1.3",
"@sammo-ts/common": "workspace:*", "@sammo-ts/common": "workspace:*",
"@sammo-ts/game-engine": "workspace:*", "@sammo-ts/game-engine": "workspace:*",
"@sammo-ts/infra": "workspace:*", "@sammo-ts/infra": "workspace:*",
@@ -43,7 +43,7 @@
"fastify": "^5.6.2", "fastify": "^5.6.2",
"redis": "^5.10.0", "redis": "^5.10.0",
"sanitize-html": "2.17.6", "sanitize-html": "2.17.6",
"sharp": "^0.34.4", "sharp": "^0.35.0",
"zod": "^4.3.5" "zod": "^4.3.5"
} }
} }
+3 -2
View File
@@ -228,6 +228,7 @@ export const boardRouter = router({
} }
const format = metadata.format; const format = metadata.format;
const isAvif = metadata.mediaType === 'image/avif';
const isAnimated = (metadata.pages ?? 1) > 1; const isAnimated = (metadata.pages ?? 1) > 1;
const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE; const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE;
@@ -237,9 +238,9 @@ export const boardRouter = router({
} }
let outputBuffer = buffer; let outputBuffer = buffer;
let outputFormat = format === 'avif' ? 'avif' : 'webp'; let outputFormat = isAvif ? 'avif' : 'webp';
if (format === 'avif') { if (isAvif) {
if (needsResize) { if (needsResize) {
outputBuffer = await buildAvifBuffer(buffer, true); outputBuffer = await buildAvifBuffer(buffer, true);
} }
+23
View File
@@ -288,6 +288,29 @@ describe('board router actor, nation, and secret permissions', () => {
expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false }); expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false });
}); });
it('preserves an AVIF editor image when resizing is unnecessary', async () => {
const upload = vi.fn(async ({ filename }: { filename: string }) => ({
publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`,
}));
const fixture = buildContext({
me: buildGeneral({ officerLevel: 5 }),
contentImageUpload: { upload },
});
const avif = await sharp({
create: { width: 64, height: 48, channels: 4, background: '#224466' },
})
.avif()
.toBuffer();
const result = await appRouter.createCaller(fixture.context).board.uploadImage({
dataUrl: `data:image/avif;base64,${avif.toString('base64')}`,
});
expect(result.url).toMatch(/^https:\/\/sam-image\.hided\.net\/uploads\/core2026\/[a-f0-9]{32}\.avif$/);
expect(upload).toHaveBeenCalledWith(expect.objectContaining({ contentType: 'image/avif', body: avif }));
expect(result).toMatchObject({ width: 64, height: 48, format: 'avif', animated: false });
});
it('rejects editor image uploads from an ordinary nation member', async () => { it('rejects editor image uploads from an ordinary nation member', async () => {
const upload = vi.fn(); const upload = vi.fn();
const fixture = buildContext({ const fixture = buildContext({
@@ -204,9 +204,8 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
await expect( await expect(
appRouter.createCaller(buildContext('select-pool-public-lobby')).lobby.info() appRouter.createCaller(buildContext('select-pool-public-lobby')).lobby.info()
).resolves.toMatchObject({ selectionPoolEnabled: true }); ).resolves.toMatchObject({ selectionPoolEnabled: true });
await expect( const joinConfig = await appRouter.createCaller(buildContext('select-pool-config')).join.getConfig();
appRouter.createCaller(buildContext('select-pool-config')).join.getConfig() expect(joinConfig).toMatchObject({
).resolves.toMatchObject({
serverInfo: { serverInfo: {
currentYear: 180, currentYear: 180,
currentMonth: 1, currentMonth: 1,
@@ -392,6 +391,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
const fullWorld = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }); const fullWorld = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
const fullConfig = fullWorld.config as Record<string, unknown>; const fullConfig = fullWorld.config as Record<string, unknown>;
runtime!.world.updateWorldConfig({ maxGeneral: 1 });
await db.worldState.update({ await db.worldState.update({
where: { id: worldStateId }, where: { id: worldStateId },
data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue }, data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue },
@@ -427,6 +427,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
}) })
).rejects.toMatchObject({ message: '더 이상 등록 할 수 없습니다.' }); ).rejects.toMatchObject({ message: '더 이상 등록 할 수 없습니다.' });
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0); expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
runtime!.world.updateWorldConfig({ maxGeneral: joinConfig.serverInfo.maxGeneral });
await db.worldState.update({ await db.worldState.update({
where: { id: worldStateId }, where: { id: worldStateId },
data: { config: fullConfig as GamePrisma.InputJsonValue }, data: { config: fullConfig as GamePrisma.InputJsonValue },
+1 -1
View File
@@ -489,7 +489,7 @@ export class InMemoryTurnWorld {
// Runtime callbacks created before the world keep the original object // Runtime callbacks created before the world keep the original object
// reference. Mutate this object in place so a live settings action is // reference. Mutate this object in place so a live settings action is
// observed by monthly handlers without restarting the daemon. // observed by monthly handlers without restarting the daemon.
this.worldConfig = snapshot.worldConfig ?? {}; this.worldConfig = snapshot.worldConfig ?? { ...snapshot.scenarioConfig };
this.unitSet = snapshot.unitSet; this.unitSet = snapshot.unitSet;
this.schedule = options.schedule; this.schedule = options.schedule;
this.generalTurnHandler = this.generalTurnHandler =
@@ -82,6 +82,18 @@ const buildWorld = (stateOverride: Partial<TurnWorldState> = {}): InMemoryTurnWo
}; };
describe('runtime clock shift', () => { describe('runtime clock shift', () => {
it('preserves the scenario config when the raw world config is unavailable', () => {
const world = buildWorld();
expect(world.getWorldConfig()).toMatchObject({
stat: { total: 300 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'default' },
});
});
it.each([ it.each([
['accelerates', -15, '2026-07-30T09:45:00.000Z', '2026-07-30T09:55:00.000Z'], ['accelerates', -15, '2026-07-30T09:45:00.000Z', '2026-07-30T09:55:00.000Z'],
['delays', 15, '2026-07-30T10:15:00.000Z', '2026-07-30T10:25:00.000Z'], ['delays', 15, '2026-07-30T10:15:00.000Z', '2026-07-30T10:25:00.000Z'],
@@ -1161,6 +1161,80 @@ test('shows every Ref chief command in the exact category and command order', as
await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') }); await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') });
}); });
test('keeps general and chief command categories after input and across page reloads', async ({ page, context }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
const generalEditor = page.locator('[data-command-scope="general"]');
await generalEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
let picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '군사', exact: true }).click();
await picker.getByRole('button', { name: '출병', exact: true }).click();
await picker.getByTestId('command-argument-form').locator('select').selectOption('3');
await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(generalEditor.locator('.action-column > div').first()).toHaveText('【단양】으로 출병');
await generalEditor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await expect(picker.getByRole('button', { name: '군사', exact: true })).toHaveClass(/active/);
await expect(picker.getByRole('button', { name: '출병', exact: true })).toBeVisible();
await picker.screenshot({ path: test.info().outputPath('general-category-after-input-desktop-1200.png') });
await picker.getByRole('button', { name: '명령 입력 닫기', exact: true }).click();
await expect
.poll(() => page.evaluate(() => localStorage.getItem('core2026:general:1:category')))
.toBe(JSON.stringify('general:군사'));
const reloadedGeneralPage = await context.newPage();
await install(reloadedGeneralPage);
await reloadedGeneralPage.goto('/');
const reloadedGeneralEditor = reloadedGeneralPage.locator('[data-command-scope="general"]');
await reloadedGeneralEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const reloadedGeneralPicker = reloadedGeneralPage.getByTestId('command-picker');
await expect(reloadedGeneralPicker.getByRole('button', { name: '군사', exact: true })).toHaveClass(/active/);
await expect(reloadedGeneralPicker.getByRole('button', { name: '출병', exact: true })).toBeVisible();
await reloadedGeneralPicker.screenshot({
path: test.info().outputPath('general-category-after-reload-desktop-1200.png'),
});
await reloadedGeneralPage.close();
const chiefPage = await context.newPage();
await install(chiefPage, false, refChiefCommandTable);
await chiefPage.setViewportSize({ width: 500, height: 900 });
await chiefPage.goto('/che/chief-center');
const chiefEditor = chiefPage.locator('[data-command-scope="nation"]');
await chiefEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
picker = chiefPage.getByTestId('command-picker');
await picker.getByRole('button', { name: '전략', exact: true }).click();
await picker.getByRole('button', { name: '필사즉생', exact: true }).click();
await expect(chiefEditor.locator('.action-column > div').first()).toHaveText('필사즉생');
await chiefEditor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
picker = chiefPage.getByTestId('command-picker');
await expect(picker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/);
await expect(picker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible();
await picker.screenshot({ path: test.info().outputPath('chief-category-after-input-mobile-500.png') });
await picker.getByRole('button', { name: '명령 입력 닫기', exact: true }).click();
await expect
.poll(() => chiefPage.evaluate(() => localStorage.getItem('core2026:nation:1:5:category')))
.toBe(JSON.stringify('nation:전략'));
await chiefPage.close();
const reloadedChiefPage = await context.newPage();
await install(reloadedChiefPage, false, refChiefCommandTable);
await reloadedChiefPage.setViewportSize({ width: 500, height: 900 });
await reloadedChiefPage.goto('/che/chief-center');
const reloadedChiefEditor = reloadedChiefPage.locator('[data-command-scope="nation"]');
await reloadedChiefEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const reloadedChiefPicker = reloadedChiefPage.getByTestId('command-picker');
await expect(reloadedChiefPicker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/);
await expect(reloadedChiefPicker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible();
await expect.poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(200);
await reloadedChiefPicker.screenshot({
path: test.info().outputPath('chief-category-after-reload-mobile-500.png'),
});
});
test('shows all 12 advanced chief turns before the actions and uses the full mobile chief matrix', async ({ page }) => { test('shows all 12 advanced chief turns before the actions and uses the full mobile chief matrix', async ({ page }) => {
await install(page); await install(page);
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
@@ -73,7 +73,7 @@ const categories = computed(() => {
return [...general, ...nation]; return [...general, ...nation];
}); });
const selectedCategory = ref(''); const selectedCategory = ref(props.activeCategory ?? '');
const selectedGroup = computed(() => { const selectedGroup = computed(() => {
if (!props.commandTable) { if (!props.commandTable) {
return null; return null;
@@ -89,9 +89,7 @@ const selectedGroup = computed(() => {
watch( watch(
() => props.activeCategory, () => props.activeCategory,
(value) => { (value) => {
if (value) { selectedCategory.value = value ?? '';
selectedCategory.value = value;
}
} }
); );
@@ -109,11 +107,15 @@ watch(
{ immediate: true } { immediate: true }
); );
watch(selectedCategory, (value) => { watch(
selectedCategory,
(value) => {
if (value) { if (value) {
emit('update:activeCategory', value); emit('update:activeCategory', value);
} }
}); },
{ immediate: true }
);
const commandTitle = (command: CommandAvailability) => const commandTitle = (command: CommandAvailability) =>
command.reason || (command.reqArg ? '대상을 선택하는 명령입니다.' : command.possible ? '실행 가능' : '실행 불가'); command.reason || (command.reqArg ? '대상을 선택하는 명령입니다.' : command.possible ? '실행 가능' : '실행 불가');
@@ -35,6 +35,8 @@ const nationCommands = [
'che_물자원조', 'che_물자원조',
]; ];
const capitalCommands = ['che_증축', 'che_감축'];
const otherArgumentCommands = [ const otherArgumentCommands = [
'che_증여', 'che_증여',
'che_헌납', 'che_헌납',
@@ -58,7 +60,7 @@ const otherArgumentCommands = [
]; ];
void test('provides Ref-level guidance for every in-scope argument command', () => { void test('provides Ref-level guidance for every in-scope argument command', () => {
const expected = [...cityCommands, ...nationCommands, ...otherArgumentCommands].sort(); const expected = [...cityCommands, ...nationCommands, ...capitalCommands, ...otherArgumentCommands].sort();
assert.deepEqual(presentedCommandKeys().sort(), expected); assert.deepEqual(presentedCommandKeys().sort(), expected);
for (const commandKey of expected) { for (const commandKey of expected) {
assert.ok(commandArgumentPresentation(commandKey).lines.join(' ').length >= 12, commandKey); assert.ok(commandArgumentPresentation(commandKey).lines.join(' ').length >= 12, commandKey);
@@ -76,4 +78,7 @@ void test('marks the same city and nation target families that Ref renders with
for (const commandKey of nationCommands) { for (const commandKey of nationCommands) {
assert.equal(commandArgumentPresentation(commandKey).mapTarget, 'nation', commandKey); assert.equal(commandArgumentPresentation(commandKey).mapTarget, 'nation', commandKey);
} }
for (const commandKey of capitalCommands) {
assert.equal(commandArgumentPresentation(commandKey).mapTarget, 'capital', commandKey);
}
}); });
@@ -104,7 +104,7 @@ const table: CommandTable = {
}, },
}; };
test('Ref getBrief를 상속하는 출병·계략·모병까지 실제 인자 요약으로 표시한다', () => { void test('Ref getBrief를 상속하는 출병·계략·모병까지 실제 인자 요약으로 표시한다', () => {
assert.equal(formatReservedCommandBrief('general', 'che_출병', { destCityId: 2 }, table), '【단양】으로 출병'); assert.equal(formatReservedCommandBrief('general', 'che_출병', { destCityId: 2 }, table), '【단양】으로 출병');
assert.equal(formatReservedCommandBrief('general', 'che_화계', { destCityId: 3 }, table), '【업】에 화계실행'); assert.equal(formatReservedCommandBrief('general', 'che_화계', { destCityId: 3 }, table), '【업】에 화계실행');
assert.equal(formatReservedCommandBrief('general', 'che_선동', { destCityId: 2 }, table), '【단양】에 선동실행'); assert.equal(formatReservedCommandBrief('general', 'che_선동', { destCityId: 2 }, table), '【단양】에 선동실행');
@@ -114,7 +114,7 @@ test('Ref getBrief를 상속하는 출병·계략·모병까지 실제 인자
); );
}); });
test('개인·인사·국가 명령의 Ref brief 변형을 보존한다', () => { void test('개인·인사·국가 명령의 Ref brief 변형을 보존한다', () => {
const cases: Array<[string, Record<string, unknown>, string]> = [ const cases: Array<[string, Record<string, unknown>, string]> = [
['che_이동', { destCityId: 3 }, '【업】으로 이동'], ['che_이동', { destCityId: 3 }, '【업】으로 이동'],
['che_강행', { destCityId: 2 }, '【단양】으로 강행'], ['che_강행', { destCityId: 2 }, '【단양】으로 강행'],
@@ -139,7 +139,7 @@ test('개인·인사·국가 명령의 Ref brief 변형을 보존한다', () =>
} }
}); });
test('국가 명령의 도시·국가·장수·자원 인자를 Ref brief로 표시한다', () => { void test('국가 명령의 도시·국가·장수·자원 인자를 Ref brief로 표시한다', () => {
const cases: Array<[string, Record<string, unknown>, string]> = [ const cases: Array<[string, Record<string, unknown>, string]> = [
['che_발령', { destGeneralId: 8, destCityId: 3 }, '【손권】【업】으로 발령'], ['che_발령', { destGeneralId: 8, destCityId: 3 }, '【손권】【업】으로 발령'],
['che_부대탈퇴지시', { destGeneralId: 8 }, '【손권】부대 탈퇴 지시'], ['che_부대탈퇴지시', { destGeneralId: 8 }, '【손권】부대 탈퇴 지시'],
@@ -177,7 +177,7 @@ test('국가 명령의 도시·국가·장수·자원 인자를 Ref brief로 표
); );
}); });
test('Ref가 getBrief를 재정의하지 않은 명령은 실제 표시명을 유지한다', () => { void test('Ref가 getBrief를 재정의하지 않은 명령은 실제 표시명을 유지한다', () => {
assert.equal(formatReservedCommandBrief('general', '휴식', {}, table), '휴식'); assert.equal(formatReservedCommandBrief('general', '휴식', {}, table), '휴식');
assert.equal(formatReservedCommandBrief('general', 'che_훈련', {}, table), '훈련'); assert.equal(formatReservedCommandBrief('general', 'che_훈련', {}, table), '훈련');
assert.equal(formatReservedCommandBrief('nation', 'che_필사즉생', {}, table), '필사즉생'); assert.equal(formatReservedCommandBrief('nation', 'che_필사즉생', {}, table), '필사즉생');
+3 -3
View File
@@ -30,7 +30,7 @@
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^10.1.3",
"@prisma/client": "^7.9.1", "@prisma/client": "^7.9.1",
"@sammo-ts/common": "workspace:*", "@sammo-ts/common": "workspace:*",
"@sammo-ts/game-engine": "workspace:*", "@sammo-ts/game-engine": "workspace:*",
@@ -40,10 +40,10 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"es-toolkit": "^1.43.0", "es-toolkit": "^1.43.0",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"pm2": "^5.4.3", "pm2": "^7.0.3",
"redis": "^5.10.0", "redis": "^5.10.0",
"sanitize-html": "2.17.6", "sanitize-html": "2.17.6",
"sharp": "^0.34.4", "sharp": "^0.35.0",
"zod": "^4.3.5" "zod": "^4.3.5"
} }
} }
+3 -2
View File
@@ -202,7 +202,8 @@ export const accountRouter = router({
const profiles = await listIconSyncProfiles(ctx, user.id); const profiles = await listIconSyncProfiles(ctx, user.id);
const buffer = decodeImage(input.imageData); const buffer = decodeImage(input.imageData);
const metadata = await sharp(buffer, { animated: true }).metadata(); const metadata = await sharp(buffer, { animated: true }).metadata();
if (!metadata.format || !ALLOWED_ICON_FORMATS.has(metadata.format)) { const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.', message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
@@ -214,7 +215,7 @@ export const accountRouter = router({
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.', message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
}); });
} }
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format; const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
const filename = `${randomBytes(16).toString('hex')}.${extension}`; const filename = `${randomBytes(16).toString('hex')}.${extension}`;
if (!ctx.userIconUpload) { if (!ctx.userIconUpload) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' }); throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
+31
View File
@@ -1359,6 +1359,37 @@ describe('account self service', () => {
} }
}); });
it('stores AVIF account icons with the public AVIF extension and media type', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-avif-'));
try {
const { caller, users, sessions, userIconUpload } = buildCaller({ userIconDir: iconDir });
const user = await users.createUser({
username: 'icon-avif',
password: 'current-password',
});
const session = await sessions.createSession(user);
const avif = await sharp({
create: { width: 64, height: 64, channels: 4, background: '#334455' },
})
.avif()
.toBuffer();
const result = await caller.account.changeIcon({
sessionToken: session.sessionToken,
imageData: `data:image/avif;base64,${avif.toString('base64')}`,
});
expect(result.iconUrl).toMatch(
/^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{32}\.avif$/
);
expect(userIconUpload.upload).toHaveBeenCalledWith(
expect.objectContaining({ contentType: 'image/avif', body: avif })
);
} finally {
await fs.rm(iconDir, { recursive: true, force: true });
}
});
it('atomically allows only one icon change per KST day and removes the losing file', async () => { it('atomically allows only one icon change per KST day and removes the losing file', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-')); const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
try { try {
+3 -3
View File
@@ -43,9 +43,6 @@ outline: deep
| `che_이동` | 이동 | 필요 | 1턴 | | `che_이동` | 이동 | 필요 | 1턴 |
| `che_방랑` | 방랑 | 없음 | 1턴 | | `che_방랑` | 방랑 | 없음 | 1턴 |
| `che_첩보` | 첩보 | 필요 | 1턴 | | `che_첩보` | 첩보 | 필요 | 1턴 |
| `che_파괴` | 파괴 | 필요 | 1턴 |
| `che_선동` | 선동 | 필요 | 1턴 |
| `che_탈취` | 탈취 | 필요 | 1턴 |
| `che_강행` | 강행 | 필요 | 1턴 | | `che_강행` | 강행 | 필요 | 1턴 |
### 인사 ### 인사
@@ -104,6 +101,9 @@ outline: deep
| 내부 키 | 화면 이름 | 대상·수량 입력 | 기본 실행 단위 | | 내부 키 | 화면 이름 | 대상·수량 입력 | 기본 실행 단위 |
| ---------- | --------- | -------------- | -------------- | | ---------- | --------- | -------------- | -------------- |
| `che_화계` | 화계 | 필요 | 1턴 | | `che_화계` | 화계 | 필요 | 1턴 |
| `che_파괴` | 파괴 | 필요 | 1턴 |
| `che_선동` | 선동 | 필요 | 1턴 |
| `che_탈취` | 탈취 | 필요 | 1턴 |
### 특수 ### 특수
@@ -1,4 +1,4 @@
CREATE TABLE "legacy_archive"."battle_result_import_run" ( CREATE TABLE IF NOT EXISTS "legacy_archive"."battle_result_import_run" (
"id" BIGSERIAL PRIMARY KEY, "id" BIGSERIAL PRIMARY KEY,
"source_profile" TEXT NOT NULL, "source_profile" TEXT NOT NULL,
"source_key" TEXT NOT NULL, "source_key" TEXT NOT NULL,
@@ -19,10 +19,10 @@ CREATE TABLE "legacy_archive"."battle_result_import_run" (
CHECK ("source_fingerprint" ~ '^[a-f0-9]{64}$') CHECK ("source_fingerprint" ~ '^[a-f0-9]{64}$')
); );
CREATE INDEX "legacy_archive_battle_result_run_source_started" CREATE INDEX IF NOT EXISTS "legacy_archive_battle_result_run_source_started"
ON "legacy_archive"."battle_result_import_run" ("source_profile", "source_key", "started_at" DESC); ON "legacy_archive"."battle_result_import_run" ("source_profile", "source_key", "started_at" DESC);
CREATE TABLE "legacy_archive"."general_battle_result" ( CREATE TABLE IF NOT EXISTS "legacy_archive"."general_battle_result" (
"source_profile" TEXT NOT NULL, "source_profile" TEXT NOT NULL,
"server_id" TEXT NOT NULL, "server_id" TEXT NOT NULL,
"general_no" INTEGER NOT NULL, "general_no" INTEGER NOT NULL,
@@ -41,7 +41,7 @@ CREATE TABLE "legacy_archive"."general_battle_result" (
CONSTRAINT "legacy_archive_general_battle_result_hash_check" CHECK ("content_hash" ~ '^[a-f0-9]{64}$') CONSTRAINT "legacy_archive_general_battle_result_hash_check" CHECK ("content_hash" ~ '^[a-f0-9]{64}$')
); );
CREATE TABLE "legacy_archive"."battle_result_import_checkpoint" ( CREATE TABLE IF NOT EXISTS "legacy_archive"."battle_result_import_checkpoint" (
"source_profile" TEXT NOT NULL, "source_profile" TEXT NOT NULL,
"source_key" TEXT NOT NULL, "source_key" TEXT NOT NULL,
"source_fingerprint" CHAR(64) NOT NULL, "source_fingerprint" CHAR(64) NOT NULL,
+1129 -1790
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -4,6 +4,10 @@ packages:
- tools/* - tools/*
overrides: overrides:
deepmerge-ts: 8.0.1
fast-uri: 3.1.5
js-yaml: 4.3.1
lodash: 4.18.1
postcss: 8.5.26 postcss: 8.5.26
typescript: 6.0.3 typescript: 6.0.3
-67
View File
@@ -1,67 +0,0 @@
> @sammo-ts/logic@0.0.0 test /home/letrhee/core2026/packages/logic
> vitest run --config vitest.config.ts -- test/scenarios/blankStart.test.ts --run
 RUN  v4.0.16 /home/letrhee/core2026/packages/logic
✓ test/message.test.ts (4 tests) 4ms
✓ test/worldBootstrap.test.ts (1 test) 3ms
✓ test/crewType.test.ts (2 tests) 3ms
✓ test/scenarioParser.test.ts (3 tests) 12ms
stdout | test/scenarios/domestic.test.ts > Domestic Affairs Scenario > should increase agriculture when executing "Farming" command
Agriculture: 500 -> 600
✓ test/scenarios/domestic.test.ts (2 tests) 6ms
 test/scenarios/blankStart.test.ts (3 tests | 1 failed) 14ms
 × should follow the correct founding scenario (uprising -> appointment -> founding) 12ms
✓ should fail founding if city is not level 5 or 6 1ms
✓ should fail founding after opening part 0ms
stdout | test/scenarios/troops.test.ts > Troop Management Scenario > should successfully draft troops, then train and boost morale
Drafted: 1000
stdout | test/scenarios/troops.test.ts > Troop Management Scenario > should successfully draft troops, then train and boost morale
Train: 10 -> 75
stdout | test/scenarios/troops.test.ts > Troop Management Scenario > should successfully draft troops, then train and boost morale
Atmos: 10 -> 75
✓ test/scenarios/troops.test.ts (1 test) 7ms
✓ test/warAftermath.test.ts (2 tests) 4ms
stdout | test/scenarios/diplomacy.test.ts > Diplomacy Scenario > should handle War Declaration and prevent/allow deployment accordingly
General Exp: 100 -> 100
✓ test/dispatchWarAction.test.ts (1 test) 7ms
✓ test/scenarios/diplomacy.test.ts (1 test) 7ms
✓ test/warEngine.test.ts (2 tests) 11ms
✓ test/scenarios/general_commands_new.test.ts (2 tests) 17ms
✓ test/specialActions.test.ts (4 tests) 28ms
✓ test/diplomacy.test.ts (4 tests) 2ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
 FAIL  test/scenarios/blankStart.test.ts > Blank Start Scenario > should follow the correct founding scenario (uprising -> appointment -> founding)
AssertionError: expected 'deny' to be 'allow' // Object.is equality
Expected: "allow"
Received: "deny"
  test/scenarios/blankStart.test.ts:252:30
250|  for (const constraint of foundNationDef.buildConstraints(final…
251|  const res = constraint.test(finalCtx, finalView);
252|  expect(res.kind).toBe('allow');
 |  ^
253|  }
254| 
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
 Test Files  1 failed | 13 passed (14)
 Tests  1 failed | 31 passed (32)
 Start at  14:11:35
 Duration  587ms (transform 2.41s, setup 0ms, import 3.55s, tests 123ms, environment 2ms)
/home/letrhee/core2026/packages/logic:
ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL @sammo-ts/logic@0.0.0 test: `vitest run --config vitest.config.ts -- test/scenarios/blankStart.test.ts --run`
Exit status 1
+1 -1
View File
@@ -13,7 +13,7 @@ NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession
PROFILE_SEED_CLI_DATABASE_URL core PROFILE_SEED_CLI_DATABASE_URL core
PROFILE_SEED_DATABASE_URL core PROFILE_SEED_DATABASE_URL core
PROFILE_LOCK_SECONDARY_DATABASE_URL core PROFILE_LOCK_SECONDARY_DATABASE_URL core
READ_MODEL_JOURNAL_DATABASE_URL core READ_MODEL_JOURNAL_DATABASE_URL read_model_journal
RESERVED_TURN_DATABASE_URL core RESERVED_TURN_DATABASE_URL core
SELECT_POOL_DATABASE_URL select_pool SELECT_POOL_DATABASE_URL select_pool
TURN_DAEMON_LEASE_DATABASE_URL core TURN_DAEMON_LEASE_DATABASE_URL core
1 # Environment variable Execution mode
13 PROFILE_SEED_CLI_DATABASE_URL core
14 PROFILE_SEED_DATABASE_URL core
15 PROFILE_LOCK_SECONDARY_DATABASE_URL core
16 READ_MODEL_JOURNAL_DATABASE_URL core read_model_journal
17 RESERVED_TURN_DATABASE_URL core
18 SELECT_POOL_DATABASE_URL select_pool
19 TURN_DAEMON_LEASE_DATABASE_URL core
@@ -38,6 +38,7 @@ const parseCommand = async (scope, key) => {
const actionName = const actionName =
readQuoted(source, /const ACTION_NAME\s*=\s*['"]([^'"]+)['"]/) ?? readQuoted(source, /const ACTION_NAME\s*=\s*['"]([^'"]+)['"]/) ??
readQuoted(source, /public (?:override )?readonly name(?:\s*:\s*string)?\s*=\s*['"]([^'"]+)['"]/) ?? readQuoted(source, /public (?:override )?readonly name(?:\s*:\s*string)?\s*=\s*['"]([^'"]+)['"]/) ??
readQuoted(source, /const CONFIG[\s\S]*?\bname:\s*['"]([^'"]+)['"]/) ??
readQuoted(source, /const DEFAULT_CONFIG[\s\S]*?\bname:\s*['"]([^'"]+)['"]/) ?? readQuoted(source, /const DEFAULT_CONFIG[\s\S]*?\bname:\s*['"]([^'"]+)['"]/) ??
readQuoted(source, /(?:super|createEventResearchCommand)\([\s\S]*?\{[\s\S]*?\bname:\s*['"]([^'"]+)['"]/); readQuoted(source, /(?:super|createEventResearchCommand)\([\s\S]*?\{[\s\S]*?\bname:\s*['"]([^'"]+)['"]/);
const category = const category =
+18 -3
View File
@@ -41,7 +41,7 @@ node_tag=$(printf '%s' "${CI_NODE_INDEX:-local}" | tr -cd 'a-zA-Z0-9_' | tr 'A-Z
run_id=$(date -u +%m%d%H%M%S)_$$_${node_tag} run_id=$(date -u +%m%d%H%M%S)_$$_${node_tag}
export CONDITIONAL_INTEGRATION_RUN_ID=$run_id export CONDITIONAL_INTEGRATION_RUN_ID=$run_id
schema_ownership_token="sammo-conditional-integration:$run_id" schema_ownership_token="sammo-conditional-integration:$run_id"
supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession reference_live_sortie reference_npc_possession select_pool" supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession read_model_journal reference_live_sortie reference_npc_possession select_pool"
term_grace_seconds=${CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS:-10} term_grace_seconds=${CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS:-10}
case "$term_grace_seconds" in case "$term_grace_seconds" in
''|*[!0-9]*) ''|*[!0-9]*)
@@ -60,6 +60,7 @@ create_general_schema=${CREATE_GENERAL_INTEGRATION_SCHEMA:-ci_${run_id}_create_g
select_pool_schema=${SELECT_POOL_INTEGRATION_SCHEMA:-ci_${run_id}_select_pool_integration} select_pool_schema=${SELECT_POOL_INTEGRATION_SCHEMA:-ci_${run_id}_select_pool_integration}
immediate_action_schema=${IMMEDIATE_ACTION_INTEGRATION_SCHEMA:-ci_${run_id}_immediate_action_integration} immediate_action_schema=${IMMEDIATE_ACTION_INTEGRATION_SCHEMA:-ci_${run_id}_immediate_action_integration}
gateway_runtime_schema=${GATEWAY_RUNTIME_INTEGRATION_SCHEMA:-ci_${run_id}_gateway_runtime_integration} gateway_runtime_schema=${GATEWAY_RUNTIME_INTEGRATION_SCHEMA:-ci_${run_id}_gateway_runtime_integration}
read_model_journal_schema=${READ_MODEL_JOURNAL_INTEGRATION_SCHEMA:-ci_${run_id}_read_model_journal_integration}
npc_possession_differential_schema=${NPC_POSSESSION_DIFFERENTIAL_SCHEMA:-ci_${run_id}_npc_possession_differential} npc_possession_differential_schema=${NPC_POSSESSION_DIFFERENTIAL_SCHEMA:-ci_${run_id}_npc_possession_differential}
live_sortie_schema=${LIVE_SORTIE_PERSISTENCE_SCHEMA:-ci_${run_id}_live_sortie_persistence} live_sortie_schema=${LIVE_SORTIE_PERSISTENCE_SCHEMA:-ci_${run_id}_live_sortie_persistence}
@@ -71,6 +72,7 @@ for schema in \
"$select_pool_schema" \ "$select_pool_schema" \
"$immediate_action_schema" \ "$immediate_action_schema" \
"$gateway_runtime_schema" \ "$gateway_runtime_schema" \
"$read_model_journal_schema" \
"$npc_possession_differential_schema" \ "$npc_possession_differential_schema" \
"$live_sortie_schema"; do "$live_sortie_schema"; do
case "$schema" in case "$schema" in
@@ -485,7 +487,6 @@ export PROFILE_SEED_CLI_DATABASE_URL=$database_url
export PROFILE_SEED_DATABASE_URL=$database_url export PROFILE_SEED_DATABASE_URL=$database_url
profile_lock_secondary_database_url=$(build_database_url "$scenario_schema") profile_lock_secondary_database_url=$(build_database_url "$scenario_schema")
export PROFILE_LOCK_SECONDARY_DATABASE_URL=$profile_lock_secondary_database_url export PROFILE_LOCK_SECONDARY_DATABASE_URL=$profile_lock_secondary_database_url
export READ_MODEL_JOURNAL_DATABASE_URL=$database_url
# The infra PostgreSQL boundary tests assert migration-owned seed rows, CHECK # The infra PostgreSQL boundary tests assert migration-owned seed rows, CHECK
# constraints, and indexes. `prisma db push` only materializes the Prisma data # constraints, and indexes. `prisma db push` only materializes the Prisma data
@@ -494,11 +495,25 @@ export READ_MODEL_JOURNAL_DATABASE_URL=$database_url
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game
core_database_markers=$(markers_for_mode core) core_database_markers=$(markers_for_mode core)
run_marked_tests packages/infra "$core_database_markers" "infra_postgresql"
run_marked_tests app/game-api "$core_database_markers" "game_api_postgresql" run_marked_tests app/game-api "$core_database_markers" "game_api_postgresql"
run_marked_tests app/game-engine "$core_database_markers" "game_engine_postgresql" run_marked_tests app/game-engine "$core_database_markers" "game_engine_postgresql"
run_marked_tests tools/integration-tests "$core_database_markers" "snapshot_postgresql" run_marked_tests tools/integration-tests "$core_database_markers" "snapshot_postgresql"
create_owned_schema "$read_model_journal_schema"
read_model_journal_database_url=$(build_database_url "$read_model_journal_schema")
(
export POSTGRES_SCHEMA=$read_model_journal_schema
export DATABASE_URL=$read_model_journal_database_url
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game
)
export READ_MODEL_JOURNAL_DATABASE_URL=$read_model_journal_database_url
run_marked_tests packages/infra \
"$(markers_for_mode read_model_journal)" \
"read_model_journal_infra_postgresql"
run_marked_tests app/game-engine \
"$(markers_for_mode read_model_journal)" \
"read_model_journal_engine_postgresql"
create_owned_schema "$create_general_schema" create_owned_schema "$create_general_schema"
create_general_database_url=$(build_database_url "$create_general_schema") create_general_database_url=$(build_database_url "$create_general_schema")
( (