merge: 최신 로컬 main을 건국 색상 라벨 브랜치에 통합
This commit is contained in:
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@fastify/static": "^10.1.3",
|
||||
"@sammo-ts/common": "workspace:*",
|
||||
"@sammo-ts/game-engine": "workspace:*",
|
||||
"@sammo-ts/infra": "workspace:*",
|
||||
@@ -43,7 +43,7 @@
|
||||
"fastify": "^5.6.2",
|
||||
"redis": "^5.10.0",
|
||||
"sanitize-html": "2.17.6",
|
||||
"sharp": "^0.34.4",
|
||||
"sharp": "^0.35.0",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ export const boardRouter = router({
|
||||
}
|
||||
|
||||
const format = metadata.format;
|
||||
const isAvif = metadata.mediaType === 'image/avif';
|
||||
const isAnimated = (metadata.pages ?? 1) > 1;
|
||||
const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE;
|
||||
|
||||
@@ -237,9 +238,9 @@ export const boardRouter = router({
|
||||
}
|
||||
|
||||
let outputBuffer = buffer;
|
||||
let outputFormat = format === 'avif' ? 'avif' : 'webp';
|
||||
let outputFormat = isAvif ? 'avif' : 'webp';
|
||||
|
||||
if (format === 'avif') {
|
||||
if (isAvif) {
|
||||
if (needsResize) {
|
||||
outputBuffer = await buildAvifBuffer(buffer, true);
|
||||
}
|
||||
|
||||
@@ -288,6 +288,29 @@ describe('board router actor, nation, and secret permissions', () => {
|
||||
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 () => {
|
||||
const upload = vi.fn();
|
||||
const fixture = buildContext({
|
||||
|
||||
@@ -204,9 +204,8 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('select-pool-public-lobby')).lobby.info()
|
||||
).resolves.toMatchObject({ selectionPoolEnabled: true });
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('select-pool-config')).join.getConfig()
|
||||
).resolves.toMatchObject({
|
||||
const joinConfig = await appRouter.createCaller(buildContext('select-pool-config')).join.getConfig();
|
||||
expect(joinConfig).toMatchObject({
|
||||
serverInfo: {
|
||||
currentYear: 180,
|
||||
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 fullConfig = fullWorld.config as Record<string, unknown>;
|
||||
runtime!.world.updateWorldConfig({ maxGeneral: 1 });
|
||||
await db.worldState.update({
|
||||
where: { id: worldStateId },
|
||||
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: '더 이상 등록 할 수 없습니다.' });
|
||||
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
|
||||
runtime!.world.updateWorldConfig({ maxGeneral: joinConfig.serverInfo.maxGeneral });
|
||||
await db.worldState.update({
|
||||
where: { id: worldStateId },
|
||||
data: { config: fullConfig as GamePrisma.InputJsonValue },
|
||||
|
||||
@@ -489,7 +489,7 @@ export class InMemoryTurnWorld {
|
||||
// Runtime callbacks created before the world keep the original object
|
||||
// reference. Mutate this object in place so a live settings action is
|
||||
// observed by monthly handlers without restarting the daemon.
|
||||
this.worldConfig = snapshot.worldConfig ?? {};
|
||||
this.worldConfig = snapshot.worldConfig ?? { ...snapshot.scenarioConfig };
|
||||
this.unitSet = snapshot.unitSet;
|
||||
this.schedule = options.schedule;
|
||||
this.generalTurnHandler =
|
||||
|
||||
@@ -82,6 +82,18 @@ const buildWorld = (stateOverride: Partial<TurnWorldState> = {}): InMemoryTurnWo
|
||||
};
|
||||
|
||||
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([
|
||||
['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'],
|
||||
|
||||
@@ -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') });
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
|
||||
@@ -73,7 +73,7 @@ const categories = computed(() => {
|
||||
return [...general, ...nation];
|
||||
});
|
||||
|
||||
const selectedCategory = ref('');
|
||||
const selectedCategory = ref(props.activeCategory ?? '');
|
||||
const selectedGroup = computed(() => {
|
||||
if (!props.commandTable) {
|
||||
return null;
|
||||
@@ -89,9 +89,7 @@ const selectedGroup = computed(() => {
|
||||
watch(
|
||||
() => props.activeCategory,
|
||||
(value) => {
|
||||
if (value) {
|
||||
selectedCategory.value = value;
|
||||
}
|
||||
selectedCategory.value = value ?? '';
|
||||
}
|
||||
);
|
||||
|
||||
@@ -109,11 +107,15 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(selectedCategory, (value) => {
|
||||
if (value) {
|
||||
emit('update:activeCategory', value);
|
||||
}
|
||||
});
|
||||
watch(
|
||||
selectedCategory,
|
||||
(value) => {
|
||||
if (value) {
|
||||
emit('update:activeCategory', value);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const commandTitle = (command: CommandAvailability) =>
|
||||
command.reason || (command.reqArg ? '대상을 선택하는 명령입니다.' : command.possible ? '실행 가능' : '실행 불가');
|
||||
|
||||
@@ -35,6 +35,8 @@ const nationCommands = [
|
||||
'che_물자원조',
|
||||
];
|
||||
|
||||
const capitalCommands = ['che_증축', 'che_감축'];
|
||||
|
||||
const otherArgumentCommands = [
|
||||
'che_증여',
|
||||
'che_헌납',
|
||||
@@ -58,7 +60,7 @@ const otherArgumentCommands = [
|
||||
];
|
||||
|
||||
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);
|
||||
for (const commandKey of expected) {
|
||||
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) {
|
||||
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: 3 }, 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]> = [
|
||||
['che_이동', { destCityId: 3 }, '【업】으로 이동'],
|
||||
['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]> = [
|
||||
['che_발령', { destGeneralId: 8, destCityId: 3 }, '【손권】【업】으로 발령'],
|
||||
['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', 'che_훈련', {}, table), '훈련');
|
||||
assert.equal(formatReservedCommandBrief('nation', 'che_필사즉생', {}, table), '필사즉생');
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@fastify/static": "^10.1.3",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"@sammo-ts/common": "workspace:*",
|
||||
"@sammo-ts/game-engine": "workspace:*",
|
||||
@@ -40,10 +40,10 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"es-toolkit": "^1.43.0",
|
||||
"fastify": "^5.6.2",
|
||||
"pm2": "^5.4.3",
|
||||
"pm2": "^7.0.3",
|
||||
"redis": "^5.10.0",
|
||||
"sanitize-html": "2.17.6",
|
||||
"sharp": "^0.34.4",
|
||||
"sharp": "^0.35.0",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,8 @@ export const accountRouter = router({
|
||||
const profiles = await listIconSyncProfiles(ctx, user.id);
|
||||
const buffer = decodeImage(input.imageData);
|
||||
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({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
|
||||
@@ -214,7 +215,7 @@ export const accountRouter = router({
|
||||
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
|
||||
});
|
||||
}
|
||||
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
||||
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
|
||||
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
|
||||
if (!ctx.userIconUpload) {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
|
||||
|
||||
@@ -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 () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user