merge: 최신 로컬 main을 정보 화면 버튼 통일에 통합

This commit is contained in:
2026-08-21 17:35:51 +00:00
25 changed files with 1010 additions and 84 deletions
+17 -1
View File
@@ -45,6 +45,7 @@ import {
import { import {
resolveGeneralTypeCall, resolveGeneralTypeCall,
resolveLeadershipBonus, resolveLeadershipBonus,
resolveNextTurnMonthOffset,
resolveRefreshScoreText, resolveRefreshScoreText,
resolveRemainingMinutes, resolveRemainingMinutes,
} from '../../services/generalBasicCardProjection.js'; } from '../../services/generalBasicCardProjection.js';
@@ -265,6 +266,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
dedication: true, dedication: true,
age: true, age: true,
turnTime: true, turnTime: true,
turnTick: true,
recentWarTime: true, recentWarTime: true,
crewTypeId: true, crewTypeId: true,
personalCode: true, personalCode: true,
@@ -333,7 +335,14 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
}) })
: Promise.resolve(NEUTRAL_NATION_CONTEXT), : Promise.resolve(NEUTRAL_NATION_CONTEXT),
ctx.db.worldState.findFirst({ ctx.db.worldState.findFirst({
select: { currentYear: true, currentMonth: true, tickSeconds: true, config: true, meta: true }, select: {
currentYear: true,
currentMonth: true,
tickSeconds: true,
lastTurnTick: true,
config: true,
meta: true,
},
}), }),
officerCityId > 0 officerCityId > 0
? ctx.db.city.findUnique({ where: { id: officerCityId }, select: { name: true } }) ? ctx.db.city.findUnique({ where: { id: officerCityId }, select: { name: true } })
@@ -521,6 +530,13 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
age: general.age, age: general.age,
retirementYear, retirementYear,
turnTime: general.turnTime.toISOString(), turnTime: general.turnTime.toISOString(),
nextTurnMonthOffset: resolveNextTurnMonthOffset({
turnTime: general.turnTime,
turnTick: general.turnTick,
lastExecuted: parsedLastExecuted,
lastTurnTick: worldState?.lastTurnTick,
turnSeconds: worldState?.tickSeconds ?? 0,
}),
recentWar: general.recentWarTime?.toISOString() ?? null, recentWar: general.recentWarTime?.toISOString() ?? null,
defenceTrain: settings.defence_train, defenceTrain: settings.defence_train,
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0), killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
@@ -1,3 +1,5 @@
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
export interface GeneralBasicStats { export interface GeneralBasicStats {
leadership: number; leadership: number;
strength: number; strength: number;
@@ -53,3 +55,41 @@ export const resolveRemainingMinutes = (
} }
return Math.floor(Math.min(999, Math.max(0, (nextTurnMillis - lastExecuted.getTime()) / 60_000))); return Math.floor(Math.min(999, Math.max(0, (nextTurnMillis - lastExecuted.getTime()) / 60_000)));
}; };
export interface NextTurnMonthOffsetInput {
turnTime: Date;
turnTick?: bigint | number | null;
lastExecuted: Date | null;
lastTurnTick?: bigint | number | null;
turnSeconds: number;
}
const normalizeTick = (value: bigint | number | null | undefined): bigint | null => {
if (typeof value === 'bigint') return value;
if (typeof value === 'number' && Number.isSafeInteger(value)) return BigInt(value);
return null;
};
const turnBucket = (tick: bigint): bigint => {
const ticksPerTurn = BigInt(GAME_TICKS_PER_TURN);
const quotient = tick / ticksPerTurn;
return tick % ticksPerTurn < 0 ? quotient - 1n : quotient;
};
/**
* Ref Command.GetReservedCommand cuts both clocks to a gameplay-turn bucket.
* A general in the next bucket has already acted in the displayed world month,
* so the first reserved command belongs to the following month.
*/
export const resolveNextTurnMonthOffset = (input: NextTurnMonthOffsetInput): 0 | 1 => {
const turnTick = normalizeTick(input.turnTick);
const lastTurnTick = normalizeTick(input.lastTurnTick);
if (turnTick !== null && lastTurnTick !== null) {
return turnBucket(turnTick) > turnBucket(lastTurnTick) ? 1 : 0;
}
const turnTimeMs = input.turnTime.getTime();
const lastExecutedMs = input.lastExecuted?.getTime() ?? Number.NaN;
if (!Number.isFinite(turnTimeMs) || !Number.isFinite(lastExecutedMs) || input.turnSeconds <= 0) return 0;
return turnTimeMs >= lastExecutedMs + input.turnSeconds * 1_000 ? 1 : 0;
};
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import { import {
resolveGeneralTypeCall, resolveGeneralTypeCall,
resolveLeadershipBonus, resolveLeadershipBonus,
resolveNextTurnMonthOffset,
resolveRefreshScoreText, resolveRefreshScoreText,
resolveRemainingMinutes, resolveRemainingMinutes,
} from '../src/services/generalBasicCardProjection.js'; } from '../src/services/generalBasicCardProjection.js';
@@ -41,4 +42,55 @@ describe('general basic card Ref projection', () => {
expect(resolveRemainingMinutes(new Date('2026-08-12T23:59:00.000Z'), lastExecuted, 3_600)).toBe(59); expect(resolveRemainingMinutes(new Date('2026-08-12T23:59:00.000Z'), lastExecuted, 3_600)).toBe(59);
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), null, 3_600)).toBeNull(); expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), null, 3_600)).toBeNull();
}); });
it('moves the first reserved month only after the general turn bucket has passed', () => {
const lastExecuted = new Date('2026-08-13T00:00:00.000Z');
const lastTurnTick = 36_000_000n * 11n;
expect(
resolveNextTurnMonthOffset({
turnTime: new Date('2026-08-13T00:07:00.000Z'),
turnTick: lastTurnTick + 12_000_000n,
lastExecuted,
lastTurnTick,
turnSeconds: 600,
})
).toBe(0);
expect(
resolveNextTurnMonthOffset({
turnTime: new Date('2026-08-13T00:17:00.000Z'),
turnTick: lastTurnTick + 48_000_000n,
lastExecuted,
lastTurnTick,
turnSeconds: 600,
})
).toBe(1);
expect(
resolveNextTurnMonthOffset({
turnTime: new Date('2026-08-13T00:10:00.000Z'),
turnTick: 1n,
lastExecuted,
lastTurnTick: -1n,
turnSeconds: 600,
})
).toBe(1);
});
it('keeps the same boundary for legacy Date-only schedules', () => {
const lastExecuted = new Date('2026-08-13T00:00:00.000Z');
expect(
resolveNextTurnMonthOffset({
turnTime: new Date('2026-08-13T00:07:00.000Z'),
lastExecuted,
turnSeconds: 600,
})
).toBe(0);
expect(
resolveNextTurnMonthOffset({
turnTime: new Date('2026-08-13T00:10:00.000Z'),
lastExecuted,
turnSeconds: 600,
})
).toBe(1);
});
}); });
+2 -1
View File
@@ -73,7 +73,7 @@ describe('nation HTML purification', () => {
const source = [ const source = [
'<p style="text-align:center">', '<p style="text-align:center">',
'<span style="font-family:Pretendard, sans-serif;font-size:22px;color:#123456;background-color:#fedcba">방침</span>', '<span style="font-family:Pretendard, sans-serif;font-size:22px;color:#123456;background-color:#fedcba">방침</span>',
'</p><hr><img src="https://sam-image.hided.net/uploads/core2026/0123456789abcdef0123456789abcdef.webp" alt="방침.png">', '</p><hr><img class="custom-image-align-right" src="https://sam-image.hided.net/uploads/core2026/0123456789abcdef0123456789abcdef.webp" alt="방침.png">',
].join(''); ].join('');
const clean = purifyNationHtml(source); const clean = purifyNationHtml(source);
@@ -83,6 +83,7 @@ describe('nation HTML purification', () => {
'style="font-family:Pretendard, sans-serif;font-size:22px;color:#123456;background-color:#fedcba"' 'style="font-family:Pretendard, sans-serif;font-size:22px;color:#123456;background-color:#fedcba"'
); );
expect(clean).toContain('<hr />'); expect(clean).toContain('<hr />');
expect(clean).toContain('class="custom-image-align-right"');
expect(clean).toContain( expect(clean).toContain(
'src="https://sam-image.hided.net/uploads/core2026/0123456789abcdef0123456789abcdef.webp"' 'src="https://sam-image.hided.net/uploads/core2026/0123456789abcdef0123456789abcdef.webp"'
); );
+185 -1
View File
@@ -34,6 +34,7 @@ type NavigationFixture = {
operations: string[]; operations: string[];
generalName?: string; generalName?: string;
generalTurnTime?: string; generalTurnTime?: string;
nextTurnMonthOffset?: 0 | 1;
serverTime?: string; serverTime?: string;
serverWallTime?: string; serverWallTime?: string;
clockMode?: 'realtime' | 'manual'; clockMode?: 'realtime' | 'manual';
@@ -440,6 +441,7 @@ const generalContext = (state: NavigationFixture) => ({
crewTypeName: '보병', crewTypeName: '보병',
traits: { personal: '대담', specialDomestic: '상재', specialWar: '무쌍' }, traits: { personal: '대담', specialDomestic: '상재', specialWar: '무쌍' },
turnTime: state.generalTurnTime ?? '0185-01-01T00:00:00.000Z', turnTime: state.generalTurnTime ?? '0185-01-01T00:00:00.000Z',
nextTurnMonthOffset: state.nextTurnMonthOffset ?? 0,
}, },
city: { city: {
id: 1, id: 1,
@@ -1188,7 +1190,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월'); await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월');
await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분'); await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분');
await expect(page.locator('.legacy-game-info')).not.toContainText('최근 턴:'); await expect(page.locator('.legacy-game-info')).not.toContainText('최근 턴:');
await expect(page.locator('.execution-status')).toHaveText('동작 시각: 08-13 09:05'); await expect(page.locator('.execution-status')).toHaveText('현재 시각: 08-13 09:00');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중'); await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문'); await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문');
const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => { const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => {
@@ -1623,6 +1625,105 @@ test('the repeated bottom global menu opens upward on the mobile document', asyn
await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-dropup`); await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-dropup`);
}); });
test('first reserved month crosses December only after the general turn has passed', async ({ page }, testInfo) => {
const state: NavigationFixture = {
officerLevel: 0,
permission: 0,
nationLevel: 0,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
currentYear: 179,
currentMonth: 12,
nextTurnMonthOffset: 0,
validMapImages: true,
reservedTurns: [
{ index: 0, action: '휴식', args: {} },
{ index: 1, action: '휴식', args: {} },
],
};
await installRealtimeHarness(page);
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
await waitForMainRealtime(page);
const map = page.locator('[data-main-target="map"] .map-viewer').first();
const commandPanel = page.locator('[data-main-target="commands"]').first();
const firstDate = commandPanel.locator('.date-column [data-turn-index="0"]');
const secondDate = commandPanel.locator('.date-column [data-turn-index="1"]');
await expect(map).toContainText('179年 12月');
await expect(firstDate).toHaveText('179年 12月');
await expect(secondDate).toHaveText('180年 1月');
const before = await firstDate.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: rect.toJSON(),
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
documentScrollWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
};
});
await commandPanel.screenshot({ path: testInfo.outputPath('turn-month-before.png') });
state.nextTurnMonthOffset = 1;
state.contextRevision = 'BBBBBBBBBBBBBBBBBBBBBB';
state.contextOperations = [{ op: 'replace', path: '/general/nextTurnMonthOffset', value: 1 }];
await emitReadModelInvalidation(page, readModelInvalidation({ context: true }));
await expect(map).toContainText('179年 12月');
await expect(firstDate).toHaveText('180年 1月');
await expect(secondDate).toHaveText('180年 2月');
await firstDate.hover();
const after = await firstDate.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: rect.toJSON(),
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
documentScrollWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
};
});
expect(after.rect.width).toBe(before.rect.width);
expect(after.rect.height).toBe(before.rect.height);
expect(after.rect.left).toBe(before.rect.left);
expect(after.rect.right).toBe(before.rect.right);
expect(after.fontSize).toBe(before.fontSize);
expect(after.lineHeight).toBe(before.lineHeight);
expect(after.color).toBe(before.color);
expect(before.documentScrollWidth).toBe(before.viewportWidth);
expect(after.documentScrollWidth).toBe(after.viewportWidth);
await commandPanel.screenshot({ path: testInfo.outputPath('turn-month-after.png') });
await page.setViewportSize({ width: 390, height: 844 });
await firstDate.scrollIntoViewIfNeeded();
await expect(firstDate).toHaveText('180年 1月');
const mobile = await firstDate.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: rect.toJSON(),
fontSize: style.fontSize,
lineHeight: style.lineHeight,
documentScrollWidth: document.documentElement.scrollWidth,
};
});
expect(mobile.rect.left).toBeGreaterThanOrEqual(0);
expect(mobile.rect.right).toBeLessThanOrEqual(500);
expect(mobile.fontSize).toBe(before.fontSize);
expect(mobile.lineHeight).toBe(before.lineHeight);
expect(mobile.documentScrollWidth).toBe(500);
await commandPanel.screenshot({ path: testInfo.outputPath('turn-month-mobile.png') });
});
test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => { test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => {
const state: NavigationFixture = { const state: NavigationFixture = {
officerLevel: 0, officerLevel: 0,
@@ -1793,6 +1894,89 @@ test('main general card uses local turn time and command clock tracks corrected
expect(state.operations).toHaveLength(operationsBeforePreopenBoundary); expect(state.operations).toHaveLength(operationsBeforePreopenBoundary);
}); });
test('main header clock follows minute boundaries only while game-server contact is recent', async ({
page,
}, testInfo) => {
const state: NavigationFixture = {
officerLevel: 0,
permission: 0,
nationLevel: 0,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
serverTime: '2026-08-13T00:00:35.000Z',
serverWallTime: '2026-08-13T00:00:00.000Z',
clockMode: 'realtime',
clockRunning: true,
};
await installRealtimeHarness(page);
await installFixture(page, state);
await page.clock.install({ time: new Date('2026-08-13T00:00:00.000Z') });
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
await waitForMainRealtime(page);
const clock = page.locator('.execution-status');
const initialRequestCount = state.trpcRequests?.length ?? 0;
await expect(clock).toHaveText('현재 시각: 08-13 09:00');
await expect(clock).not.toHaveClass(/execution-status--stale/u);
await page.clock.runFor(25_000);
await expect(clock).toHaveText('현재 시각: 08-13 09:01');
await page.clock.runFor(21_000);
await expect(clock).toHaveClass(/execution-status--stale/u);
await expect(clock).toHaveAttribute('title', '최근 45초 동안 서버 통신이 없어 시각 갱신을 멈췄습니다.');
await expect.poll(() => clock.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(255, 0, 255)');
const staleDesktopGeometry = await clock.evaluate((element) => ({
rect: element.getBoundingClientRect().toJSON(),
overflow: element.scrollWidth - element.clientWidth,
color: getComputedStyle(element).color,
fontSize: getComputedStyle(element).fontSize,
lineHeight: getComputedStyle(element).lineHeight,
}));
expect(staleDesktopGeometry.rect.width).toBeCloseTo(333.33, 0);
expect(staleDesktopGeometry.rect.height).toBeGreaterThanOrEqual(36);
expect(staleDesktopGeometry.overflow).toBeLessThanOrEqual(0);
await clock.screenshot({ path: testInfo.outputPath('main-header-clock-stale-desktop-1200.png') });
await page.clock.runFor(60_000);
await expect(clock).toHaveText('현재 시각: 08-13 09:01');
await page.evaluate(() => {
(window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime(
'ping',
{}
);
});
await expect(clock).toHaveText('현재 시각: 08-13 09:02');
await expect(clock).not.toHaveClass(/execution-status--stale/u);
await page.clock.runFor(39_000);
await expect(clock).toHaveText('현재 시각: 08-13 09:03');
await expect.poll(() => clock.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(0, 255, 255)');
await page.setViewportSize({ width: 500, height: 900 });
const freshMobileGeometry = await clock.evaluate((element) => ({
rect: element.getBoundingClientRect().toJSON(),
overflow: element.scrollWidth - element.clientWidth,
color: getComputedStyle(element).color,
fontSize: getComputedStyle(element).fontSize,
lineHeight: getComputedStyle(element).lineHeight,
documentScrollWidth: document.documentElement.scrollWidth,
}));
expect(freshMobileGeometry.rect.width).toBeCloseTo(166.67, 0);
expect(freshMobileGeometry.overflow).toBeLessThanOrEqual(0);
expect(freshMobileGeometry.documentScrollWidth).toBe(500);
await Promise.all([
clock.screenshot({ path: testInfo.outputPath('main-header-clock-fresh-mobile-500.png') }),
writeFile(
testInfo.outputPath('main-header-clock-geometry.json'),
`${JSON.stringify({ staleDesktopGeometry, freshMobileGeometry }, null, 2)}\n`
),
]);
expect(state.trpcRequests?.length ?? 0).toBe(initialRequestCount);
});
test('message targets keep reply behavior and use nation-color contrast in labels and select options', async ({ test('message targets keep reply behavior and use nation-color contrast in labels and select options', async ({
page, page,
}) => { }) => {
+75 -1
View File
@@ -626,6 +626,21 @@ test('finance editor preserves Ref formatting controls and uploads images throug
expect(await editorFrame.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)'); expect(await editorFrame.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
expect(await editor.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)'); expect(await editor.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
await editor.fill('첫 번째 항목');
await page.getByRole('button', { name: '번호 목록' }).click();
await expect(editor.locator('ol')).toHaveCSS('list-style-type', 'decimal');
await expect(editor.locator('li')).toContainText('첫 번째 항목');
await screenshot(page, 'core-finance-numbered-list-desktop.png');
await page.getByRole('button', { name: '번호 목록' }).click();
const alignmentIcons = await page
.getByRole('toolbar', { name: '서식' })
.getByRole('button', { name: /^(왼쪽|가운데|오른쪽) 정렬$/ })
.locator('svg')
.evaluateAll((icons) => icons.map((icon) => icon.innerHTML));
expect(alignmentIcons).toHaveLength(3);
expect(new Set(alignmentIcons).size).toBe(3);
await editor.fill('서식 검증'); await editor.fill('서식 검증');
await editor.press('Control+A'); await editor.press('Control+A');
await page.getByRole('combobox', { name: '글꼴', exact: true }).selectOption('Gungsuh, serif'); await page.getByRole('combobox', { name: '글꼴', exact: true }).selectOption('Gungsuh, serif');
@@ -663,11 +678,27 @@ test('finance editor preserves Ref formatting controls and uploads images throug
await expect.poll(() => state.uploadDataUrl).toMatch(/^data:image\/png;base64,/); await expect.poll(() => state.uploadDataUrl).toMatch(/^data:image\/png;base64,/);
await expect(editor.locator('hr')).toHaveCount(1); await expect(editor.locator('hr')).toHaveCount(1);
await expect(editor.locator('img')).toHaveAttribute( const uploadedImage = editor.locator('img');
await expect(uploadedImage).toHaveAttribute(
'src', 'src',
'https://sam-image.hided.net/uploads/core2026/0123456789abcdef0123456789abcdef.webp' 'https://sam-image.hided.net/uploads/core2026/0123456789abcdef0123456789abcdef.webp'
); );
await uploadedImage.click();
const imageToolbar = page.getByRole('toolbar', { name: '이미지 정렬' });
await expect(imageToolbar).toBeVisible();
await expect(imageToolbar.getByRole('button')).toHaveCount(5);
await screenshot(page, 'core-finance-image-toolbar-desktop.png');
await imageToolbar.getByRole('button', { name: '이미지 오른쪽 정렬' }).click();
await expect(uploadedImage).toHaveClass(/custom-image-align-right/);
await expect(uploadedImage).toHaveCSS('display', 'block');
await expect(uploadedImage).toHaveCSS('margin-right', '0px');
await page.getByRole('toolbar', { name: '서식' }).getByRole('button', { name: '가운데 정렬' }).click();
await expect(uploadedImage).toHaveClass(/custom-image-align-center/);
await uploadedImage.click();
await imageToolbar.getByRole('button', { name: '이미지 오른쪽 정렬' }).click();
const imageButton = page.getByRole('button', { name: '이미지', exact: true }); const imageButton = page.getByRole('button', { name: '이미지', exact: true });
await imageButton.hover(); await imageButton.hover();
await expect(imageButton).toHaveCSS('border-color', 'rgb(157, 200, 240)'); await expect(imageButton).toHaveCSS('border-color', 'rgb(157, 200, 240)');
@@ -693,11 +724,54 @@ test('finance editor preserves Ref formatting controls and uploads images throug
expect(state.noticeMutationInput).toContain('background-color: rgb(254, 220, 186)'); expect(state.noticeMutationInput).toContain('background-color: rgb(254, 220, 186)');
expect(state.noticeMutationInput).toContain('text-align: center'); expect(state.noticeMutationInput).toContain('text-align: center');
expect(state.noticeMutationInput).toContain('<hr>'); expect(state.noticeMutationInput).toContain('<hr>');
expect(state.noticeMutationInput).toContain('class="custom-image-align-right"');
expect(state.noticeMutationInput).toContain( expect(state.noticeMutationInput).toContain(
'https://sam-image.hided.net/uploads/core2026/0123456789abcdef0123456789abcdef.webp' 'https://sam-image.hided.net/uploads/core2026/0123456789abcdef0123456789abcdef.webp'
); );
}); });
test('recruitment editor keeps the Ref 870px content width in desktop and scales it to 500px on mobile', async ({
page,
}) => {
await installFixture(page, { role: 'head', rate: 20 });
await page.setViewportSize({ width: 1000, height: 900 });
await gotoOffice(page, 'nation/finance');
await page.getByRole('button', { name: '임관 권유문 수정' }).click();
const form = page.locator('#scout-message-form');
const editorFrame = form.locator('.legacy-html-editor');
const desktop = await form.evaluate((element) => {
const frame = element.querySelector<HTMLElement>('.legacy-html-editor')!;
const formRect = element.getBoundingClientRect();
const frameRect = frame.getBoundingClientRect();
return {
formWidth: formRect.width,
frameWidth: frameRect.width,
leftInset: frameRect.left - formRect.left,
rightInset: formRect.right - frameRect.right,
};
});
expect(desktop.formWidth).toBe(1000);
expect(desktop.frameWidth).toBe(870);
expect(desktop.leftInset).toBe(130);
expect(desktop.rightInset).toBe(0);
await page.setViewportSize({ width: 500, height: 900 });
const mobile = await editorFrame.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
visualWidth: rect.width,
sourceWidth: element.clientWidth,
toolbarWidth: element.querySelector<HTMLElement>('[role="toolbar"]')!.getBoundingClientRect().width,
};
});
expect(mobile.sourceWidth).toBe(870);
expect(mobile.visualWidth).toBeCloseTo(500, 1);
expect(mobile.toolbarWidth).toBeCloseTo(500, 1);
expect(await page.locator('.page-finance').evaluate((element) => element.scrollWidth)).toBeLessThanOrEqual(500);
await screenshot(page, 'core-finance-scout-editor-mobile.png');
});
test('finance adopts the server-purified notice and scout message before rendering the saved preview', async ({ test('finance adopts the server-purified notice and scout message before rendering the saved preview', async ({
page, page,
}) => { }) => {
@@ -3,6 +3,7 @@ import { computed, onUnmounted, ref, watch } from 'vue';
import { addMinutes } from 'date-fns'; import { addMinutes } from 'date-fns';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue'; import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime'; import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime';
import { projectServerClock, sampleServerClock, type SampledServerClock } from '../../utils/serverClockProjection';
import type { import type {
CommandMapData, CommandMapData,
CommandMapLayout, CommandMapLayout,
@@ -15,7 +16,7 @@ const props = defineProps<{
commandTable: CommandTable | null; commandTable: CommandTable | null;
loading: boolean; loading: boolean;
reservedGeneralTurns: Array<{ index: number; action: string; args?: unknown }> | null; reservedGeneralTurns: Array<{ index: number; action: string; args?: unknown }> | null;
general: { id: number; turnTime?: string } | null; general: { id: number; turnTime?: string; nextTurnMonthOffset?: 0 | 1 } | null;
currentYear?: number; currentYear?: number;
currentMonth?: number; currentMonth?: number;
turnTermMinutes?: number; turnTermMinutes?: number;
@@ -44,13 +45,19 @@ const labelMap = computed(() => {
return result; return result;
}); });
const firstReservedMonth = computed(
() =>
(props.currentYear ?? 0) * 12 +
(props.currentMonth ?? 1) -
1 +
(props.general?.nextTurnMonthOffset ?? 0)
);
const rows = computed<ReservedCommandRow[]>(() => { const rows = computed<ReservedCommandRow[]>(() => {
const base = props.general?.turnTime ? new Date(props.general.turnTime) : null; const base = props.general?.turnTime ? new Date(props.general.turnTime) : null;
const term = props.turnTermMinutes ?? 0; const term = props.turnTermMinutes ?? 0;
const baseYear = props.currentYear ?? 0;
const baseMonth = props.currentMonth ?? 1;
return (props.reservedGeneralTurns ?? []).map((turn, offset) => { return (props.reservedGeneralTurns ?? []).map((turn, offset) => {
const absoluteMonth = baseYear * 12 + baseMonth - 1 + offset; const absoluteMonth = firstReservedMonth.value + offset;
const date = base && Number.isFinite(base.getTime()) ? addMinutes(base, offset * term) : null; const date = base && Number.isFinite(base.getTime()) ? addMinutes(base, offset * term) : null;
return { return {
...turn, ...turn,
@@ -70,9 +77,7 @@ const rows = computed<ReservedCommandRow[]>(() => {
const autonomousUntil = computed(() => { const autonomousUntil = computed(() => {
if (props.autorunLimit == null) return null; if (props.autorunLimit == null) return null;
const baseYear = props.currentYear ?? 0; const currentAbsoluteMonth = firstReservedMonth.value;
const baseMonth = props.currentMonth ?? 1;
const currentAbsoluteMonth = baseYear * 12 + baseMonth - 1;
const lastAutonomousMonth = props.autorunLimit - 1; const lastAutonomousMonth = props.autorunLimit - 1;
if (lastAutonomousMonth < currentAbsoluteMonth) return null; if (lastAutonomousMonth < currentAbsoluteMonth) return null;
@@ -90,27 +95,20 @@ const autonomousUntil = computed(() => {
const currentServerTime = ref('--:--:--'); const currentServerTime = ref('--:--:--');
const MAX_SERVER_CLOCK_TIMER_DELAY_MS = 60_000; const MAX_SERVER_CLOCK_TIMER_DELAY_MS = 60_000;
let sampledServerTimeMs: number | null = null; let serverClockSample: SampledServerClock | null = null;
let sampledClientTimeMs = 0;
let sampledStartDelayMs: number | null = 0;
let serverClockTimer: ReturnType<typeof setTimeout> | undefined; let serverClockTimer: ReturnType<typeof setTimeout> | undefined;
const updateServerClock = () => { const updateServerClock = () => {
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer); if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
serverClockTimer = undefined; serverClockTimer = undefined;
if (sampledServerTimeMs === null) { if (serverClockSample === null) {
currentServerTime.value = '--:--:--'; currentServerTime.value = '--:--:--';
return; return;
} }
const clientElapsedMs = Math.max(0, Date.now() - sampledClientTimeMs); const { clientElapsedMs, time: projectedTime } = projectServerClock(serverClockSample);
const elapsedGameMs =
props.clockMode === 'manual' || sampledStartDelayMs === null
? 0
: Math.max(0, clientElapsedMs - sampledStartDelayMs);
const projectedTime = new Date(sampledServerTimeMs + elapsedGameMs);
currentServerTime.value = formatLocalTimeSeconds(projectedTime); currentServerTime.value = formatLocalTimeSeconds(projectedTime);
if (props.clockMode !== 'manual' && sampledStartDelayMs !== null) { if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
const untilStartMs = sampledStartDelayMs - clientElapsedMs; const untilStartMs = serverClockSample.startDelayMs - clientElapsedMs;
serverClockTimer = setTimeout( serverClockTimer = setTimeout(
updateServerClock, updateServerClock,
untilStartMs > 0 untilStartMs > 0
@@ -123,21 +121,7 @@ const updateServerClock = () => {
watch( watch(
() => [props.serverTime, props.serverWallTime, props.clockMode, props.clockRunning, props.clockStartsAt] as const, () => [props.serverTime, props.serverWallTime, props.clockMode, props.clockRunning, props.clockStartsAt] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt]) => { ([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt]) => {
const parsed = serverTime ? new Date(serverTime).getTime() : Number.NaN; serverClockSample = sampleServerClock({ serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt });
sampledServerTimeMs = Number.isFinite(parsed) ? parsed : null;
sampledClientTimeMs = Date.now();
if (clockMode === 'manual') {
sampledStartDelayMs = null;
} else if (clockRunning !== false) {
sampledStartDelayMs = 0;
} else {
const wallTimeMs = serverWallTime ? new Date(serverWallTime).getTime() : Number.NaN;
const startsAtMs = clockStartsAt ? new Date(clockStartsAt).getTime() : Number.NaN;
sampledStartDelayMs =
Number.isFinite(wallTimeMs) && Number.isFinite(startsAtMs)
? Math.max(0, startsAtMs - wallTimeMs)
: null;
}
updateServerClock(); updateServerClock();
}, },
{ immediate: true } { immediate: true }
@@ -1,10 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed } from 'vue'; import { computed, onUnmounted, ref, watch } from 'vue';
import { resolveTournamentStageName } from '../../utils/tournamentStatus'; import { resolveTournamentStageName } from '../../utils/tournamentStatus';
import {
GAME_SERVER_ACTIVITY_FRESHNESS_MS,
gameServerActivity,
isRecentGameServerActivity,
} from '../../utils/gameServerActivity';
import {
millisecondsUntilNextMinute,
projectServerClock,
sampleServerClock,
type SampledServerClock,
} from '../../utils/serverClockProjection';
const props = defineProps<{ const props = defineProps<{
tournamentStage: number; tournamentStage: number;
serverTime?: string;
serverWallTime?: string;
clockMode?: 'realtime' | 'manual';
clockRunning?: boolean;
clockStartsAt?: string | null;
status: { status: {
onlineUserCount: number; onlineUserCount: number;
onlineNations: string; onlineNations: string;
@@ -20,16 +36,75 @@ const props = defineProps<{
}>(); }>();
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage)); const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
const lastExecutedStatus = computed(() => const currentServerTime = ref('기록 없음');
formatServerDateTime(props.status?.lastExecuted, { format: 'monthDayTime', fallback: '기록 없음' }) const hasServerClock = ref(false);
const serverClockFresh = ref(false);
const serverClockTitle = computed(() => {
if (!hasServerClock.value) return '서버 시각을 아직 받지 못했습니다.';
if (!serverClockFresh.value) return '최근 45초 동안 서버 통신이 없어 시각 갱신을 멈췄습니다.';
return undefined;
});
let serverClockSample: SampledServerClock | null = null;
let serverClockTimer: ReturnType<typeof setTimeout> | undefined;
const updateServerClock = () => {
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
serverClockTimer = undefined;
if (serverClockSample === null) {
currentServerTime.value = '기록 없음';
hasServerClock.value = false;
serverClockFresh.value = false;
return;
}
const now = Date.now();
const projection = projectServerClock(serverClockSample, now);
currentServerTime.value = formatServerDateTime(projection.time, {
format: 'monthDayTime',
fallback: '기록 없음',
});
hasServerClock.value = true;
const lastContactAt = gameServerActivity.lastContactAt.value;
serverClockFresh.value = isRecentGameServerActivity(lastContactAt, now);
if (!serverClockFresh.value || lastContactAt === null) return;
const nextDelays = [lastContactAt + GAME_SERVER_ACTIVITY_FRESHNESS_MS - now + 1];
if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
const untilStartMs = serverClockSample.startDelayMs - projection.clientElapsedMs;
nextDelays.push(untilStartMs > 0 ? untilStartMs : millisecondsUntilNextMinute(projection.time));
}
serverClockTimer = setTimeout(updateServerClock, Math.max(1, Math.min(...nextDelays)));
};
watch(
() => [props.serverTime, props.serverWallTime, props.clockMode, props.clockRunning, props.clockStartsAt] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt]) => {
serverClockSample = sampleServerClock({ serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt });
updateServerClock();
},
{ immediate: true }
); );
watch(() => gameServerActivity.lastContactAt.value, updateServerClock);
onUnmounted(() => {
if (serverClockTimer !== undefined) clearTimeout(serverClockTimer);
});
</script> </script>
<template> <template>
<section class="front-status" aria-label="접속 현황과 국가 방침"> <section class="front-status" aria-label="접속 현황과 국가 방침">
<div class="activity-status" aria-label="동작 시각, 토너먼트와 설문 진행 현황"> <div class="activity-status" aria-label="현재 시각, 토너먼트와 설문 진행 현황">
<div class="status-row execution-status" :class="{ 'execution-status--empty': !status?.lastExecuted }"> <div
동작 시각: {{ lastExecutedStatus }} class="status-row execution-status"
:class="{
'execution-status--empty': !hasServerClock,
'execution-status--stale': hasServerClock && !serverClockFresh,
}"
:title="serverClockTitle"
>
현재 시각: {{ currentServerTime }}
</div> </div>
<div class="status-row tournament-status"> <div class="status-row tournament-status">
<RouterLink to="/tournament"> <RouterLink to="/tournament">
@@ -120,6 +195,10 @@ const lastExecutedStatus = computed(() =>
color: magenta; color: magenta;
} }
.execution-status--stale {
color: magenta;
}
.vote-label { .vote-label {
color: cyan; color: cyan;
} }
@@ -1,16 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'; import { onBeforeUnmount, ref, watch } from 'vue';
import { EditorContent, useEditor } from '@tiptap/vue-3'; import { EditorContent, useEditor } from '@tiptap/vue-3';
import { BubbleMenu } from '@tiptap/vue-3/menus';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image'; import Image from '@tiptap/extension-image';
import TextAlign from '@tiptap/extension-text-align'; import TextAlign from '@tiptap/extension-text-align';
import { TextStyleKit } from '@tiptap/extension-text-style'; import { TextStyleKit } from '@tiptap/extension-text-style';
import { trpc } from '../../utils/trpc'; import { trpc } from '../../utils/trpc';
const props = withDefaults( const props = withDefaults(defineProps<{ modelValue: string; maxLength?: number; ariaLabel?: string }>(), {
defineProps<{ modelValue: string; maxLength?: number; ariaLabel?: string }>(), maxLength: 16384,
{ maxLength: 16384, ariaLabel: 'HTML 편집기' } ariaLabel: 'HTML 편집기',
); });
const emit = defineEmits<{ (event: 'update:modelValue', value: string): void }>(); const emit = defineEmits<{ (event: 'update:modelValue', value: string): void }>();
const fontFamilies = [ const fontFamilies = [
@@ -24,11 +25,41 @@ const fileInput = ref<HTMLInputElement | null>(null);
const uploadBusy = ref(false); const uploadBusy = ref(false);
const uploadError = ref<string | null>(null); const uploadError = ref<string | null>(null);
type Alignment = 'left' | 'center' | 'right';
type ImageAlignment = Alignment | 'float-left' | 'float-right';
const imageAlignmentControls: ReadonlyArray<{ value: ImageAlignment; label: string }> = [
{ value: 'float-left', label: '왼쪽 붙이기' },
{ value: 'left', label: '왼쪽 정렬' },
{ value: 'center', label: '가운데 정렬' },
{ value: 'right', label: '오른쪽 정렬' },
{ value: 'float-right', label: '오른쪽 붙이기' },
];
const AlignedImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
align: {
default: null,
parseHTML: (element) => {
for (const control of imageAlignmentControls) {
if (element.classList.contains(`custom-image-align-${control.value}`)) return control.value;
}
return null;
},
renderHTML: (attributes) =>
attributes.align ? { class: `custom-image-align-${String(attributes.align)}` } : {},
},
};
},
});
const editor = useEditor({ const editor = useEditor({
content: props.modelValue, content: props.modelValue,
extensions: [ extensions: [
StarterKit.configure({ link: { openOnClick: false } }), StarterKit.configure({ link: { openOnClick: false } }),
Image.configure({ inline: false, allowBase64: false }), AlignedImage.configure({ inline: false, allowBase64: false }),
TextAlign.configure({ types: ['heading', 'paragraph'], alignments: ['left', 'center', 'right'] }), TextAlign.configure({ types: ['heading', 'paragraph'], alignments: ['left', 'center', 'right'] }),
TextStyleKit, TextStyleKit,
], ],
@@ -85,11 +116,30 @@ const setColor = (event: Event, kind: 'foreground' | 'background') => {
const clearColors = () => editor.value?.chain().focus().unsetColor().unsetBackgroundColor().run(); const clearColors = () => editor.value?.chain().focus().unsetColor().unsetBackgroundColor().run();
const setAlignment = (alignment: Alignment) => {
if (!editor.value) return;
if (editor.value.isActive('image')) {
editor.value.chain().focus().updateAttributes('image', { align: alignment }).run();
return;
}
editor.value.chain().focus().setTextAlign(alignment).run();
};
const isAlignmentActive = (alignment: Alignment) =>
editor.value?.isActive('image')
? editor.value.isActive('image', { align: alignment })
: editor.value?.isActive({ textAlign: alignment });
const setImageAlignment = (alignment: ImageAlignment) =>
editor.value?.chain().focus().updateAttributes('image', { align: alignment }).run();
const readFileAsDataUrl = (file: File) => const readFileAsDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => { new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => reader.onload = () =>
typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('이미지를 읽을 수 없습니다.')); typeof reader.result === 'string'
? resolve(reader.result)
: reject(new Error('이미지를 읽을 수 없습니다.'));
reader.onerror = () => reject(new Error('이미지를 읽는 중 오류가 발생했습니다.')); reader.onerror = () => reject(new Error('이미지를 읽는 중 오류가 발생했습니다.'));
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
@@ -164,7 +214,12 @@ onBeforeUnmount(() => editor.value?.destroy());
<span class="legacy-html-editor__sr-only">글꼴</span> <span class="legacy-html-editor__sr-only">글꼴</span>
<select aria-label="글꼴" @change="setFontFamily"> <select aria-label="글꼴" @change="setFontFamily">
<option value="">글꼴</option> <option value="">글꼴</option>
<option v-for="font in fontFamilies" :key="font.value" :value="font.value" :style="{ fontFamily: font.value }"> <option
v-for="font in fontFamilies"
:key="font.value"
:value="font.value"
:style="{ fontFamily: font.value }"
>
{{ font.label }} {{ font.label }}
</option> </option>
</select> </select>
@@ -173,7 +228,9 @@ onBeforeUnmount(() => editor.value?.destroy());
<span class="legacy-html-editor__sr-only">크기</span> <span class="legacy-html-editor__sr-only">크기</span>
<select aria-label="글꼴 크기" @change="setFontSize"> <select aria-label="글꼴 크기" @change="setFontSize">
<option value="">크기</option> <option value="">크기</option>
<option v-for="size in fontSizes" :key="size" :value="size" :style="{ fontSize: size }">{{ size }}</option> <option v-for="size in fontSizes" :key="size" :value="size" :style="{ fontSize: size }">
{{ size }}
</option>
</select> </select>
</label> </label>
<label class="legacy-html-editor__color" title="글자색"> <label class="legacy-html-editor__color" title="글자색">
@@ -226,30 +283,36 @@ onBeforeUnmount(() => editor.value?.destroy());
</button> </button>
<button <button
type="button" type="button"
title="왼쪽 정렬" title="왼쪽 정렬 (선택한 이미지에도 적용)"
aria-label="왼쪽 정렬" aria-label="왼쪽 정렬"
:class="{ active: editor?.isActive({ textAlign: 'left' }) }" :class="{ active: isAlignmentActive('left') }"
@click="editor?.chain().focus().setTextAlign('left').run()" @click="setAlignment('left')"
> >
<svg class="legacy-html-editor__align-icon" viewBox="0 0 16 14" aria-hidden="true">
<path d="M1 1h14v2H1zM1 5h9v2H1zM1 9h14v2H1zM1 13h9v1H1z" />
</svg>
</button> </button>
<button <button
type="button" type="button"
title="가운데 정렬" title="가운데 정렬 (선택한 이미지에도 적용)"
aria-label="가운데 정렬" aria-label="가운데 정렬"
:class="{ active: editor?.isActive({ textAlign: 'center' }) }" :class="{ active: isAlignmentActive('center') }"
@click="editor?.chain().focus().setTextAlign('center').run()" @click="setAlignment('center')"
> >
<svg class="legacy-html-editor__align-icon" viewBox="0 0 16 14" aria-hidden="true">
<path d="M1 1h14v2H1zM3.5 5h9v2h-9zM1 9h14v2H1zM3.5 13h9v1h-9z" />
</svg>
</button> </button>
<button <button
type="button" type="button"
title="오른쪽 정렬" title="오른쪽 정렬 (선택한 이미지에도 적용)"
aria-label="오른쪽 정렬" aria-label="오른쪽 정렬"
:class="{ active: editor?.isActive({ textAlign: 'right' }) }" :class="{ active: isAlignmentActive('right') }"
@click="editor?.chain().focus().setTextAlign('right').run()" @click="setAlignment('right')"
> >
<svg class="legacy-html-editor__align-icon" viewBox="0 0 16 14" aria-hidden="true">
<path d="M1 1h14v2H1zM6 5h9v2H6zM1 9h14v2H1zM6 13h9v1H6z" />
</svg>
</button> </button>
<button <button
type="button" type="button"
@@ -285,6 +348,21 @@ onBeforeUnmount(() => editor.value?.destroy());
Tx Tx
</button> </button>
</div> </div>
<BubbleMenu v-if="editor" v-show="editor.isActive('image')" :editor="editor">
<div class="legacy-html-editor__image-toolbar" role="toolbar" aria-label="이미지 정렬">
<span>이미지 정렬</span>
<button
v-for="control in imageAlignmentControls"
:key="control.value"
type="button"
:class="{ active: editor.isActive('image', { align: control.value }) }"
:aria-label="`이미지 ${control.label}`"
@click="setImageAlignment(control.value)"
>
{{ control.label }}
</button>
</div>
</BubbleMenu>
<EditorContent :editor="editor" /> <EditorContent :editor="editor" />
<p v-if="uploadError" class="legacy-html-editor__error" role="alert">{{ uploadError }}</p> <p v-if="uploadError" class="legacy-html-editor__error" role="alert">{{ uploadError }}</p>
</div> </div>
@@ -331,6 +409,44 @@ onBeforeUnmount(() => editor.value?.destroy());
.legacy-html-editor__toolbar button.active { .legacy-html-editor__toolbar button.active {
background: #555; background: #555;
} }
.legacy-html-editor__align-icon {
display: block;
width: 16px;
height: 14px;
fill: currentcolor;
}
.legacy-html-editor__image-toolbar {
display: flex;
align-items: center;
gap: 2px;
border: 1px solid #9dc8f0;
padding: 3px;
background: #303030;
color: #fff;
box-shadow: 0 2px 6px rgb(0 0 0 / 45%);
}
.legacy-html-editor__image-toolbar span {
padding: 0 4px;
font-size: 12px;
}
.legacy-html-editor__image-toolbar button {
border: 1px solid transparent;
border-radius: 0;
padding: 3px 6px;
background: #303030;
color: inherit;
cursor: pointer;
font: inherit;
}
.legacy-html-editor__image-toolbar button:hover,
.legacy-html-editor__image-toolbar button:focus-visible {
border-color: #9dc8f0;
outline: 1px solid #9dc8f0;
background: #444;
}
.legacy-html-editor__image-toolbar button.active {
background: #555;
}
.legacy-html-editor__select, .legacy-html-editor__select,
.legacy-html-editor__color { .legacy-html-editor__color {
display: inline-flex; display: inline-flex;
@@ -402,4 +518,46 @@ button[aria-label='오른쪽 정렬'] {
:deep(.legacy-html-editor__content p) { :deep(.legacy-html-editor__content p) {
margin: 0 0 0.4em; margin: 0 0 0.4em;
} }
:deep(.legacy-html-editor__content ol),
:deep(.legacy-html-editor__content ul) {
margin: 0 0 0.4em;
padding-left: 2em;
list-style-position: outside;
}
:deep(.legacy-html-editor__content ol) {
list-style-type: decimal;
}
:deep(.legacy-html-editor__content ul) {
list-style-type: disc;
}
:deep(.legacy-html-editor__content li > p) {
margin: 0;
}
:deep(.legacy-html-editor__content img.ProseMirror-selectednode) {
outline: 2px solid #9dc8f0;
}
:deep(.legacy-html-editor__content img) {
max-width: 100%;
}
:deep(.legacy-html-editor__content img.custom-image-align-left) {
display: block;
margin-right: auto;
margin-left: 0;
}
:deep(.legacy-html-editor__content img.custom-image-align-center) {
display: block;
margin-right: auto;
margin-left: auto;
}
:deep(.legacy-html-editor__content img.custom-image-align-right) {
display: block;
margin-right: 0;
margin-left: auto;
}
:deep(.legacy-html-editor__content img.custom-image-align-float-left) {
float: left;
}
:deep(.legacy-html-editor__content img.custom-image-align-float-right) {
float: right;
}
</style> </style>
@@ -21,6 +21,7 @@ import {
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator'; import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery'; import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant'; import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
import { markGameServerContact } from '../utils/gameServerActivity';
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000; const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
@@ -1097,10 +1098,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
onPayload: (message) => { onPayload: (message) => {
if (!isRealtimeParticipant()) return; if (!isRealtimeParticipant()) return;
if (message.kind === 'patch') { if (message.kind === 'patch') {
markGameServerContact();
applyDashboardPatch(message.patch); applyDashboardPatch(message.patch);
return; return;
} }
realtimeStatus.value = message.status; realtimeStatus.value = message.status;
if (message.status === 'connected') markGameServerContact();
}, },
}); });
realtimeCoordinator.start(); realtimeCoordinator.start();
@@ -1153,6 +1156,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
realtimeSource = source; realtimeSource = source;
source.addEventListener('open', () => { source.addEventListener('open', () => {
markGameServerContact();
realtimeStatus.value = 'connected'; realtimeStatus.value = 'connected';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' }); realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' });
}); });
@@ -1166,6 +1170,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (!payload || payload.type !== 'readModelInvalidated') { if (!payload || payload.type !== 'readModelInvalidated') {
return; return;
} }
markGameServerContact();
readModelRefreshQueue.request(payload.invalidation, payload.refreshGrant); readModelRefreshQueue.request(payload.invalidation, payload.refreshGrant);
}); });
source.addEventListener('messagesInvalidated', (event) => { source.addEventListener('messagesInvalidated', (event) => {
@@ -1174,6 +1179,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (!payload || payload.type !== 'messagesInvalidated') { if (!payload || payload.type !== 'messagesInvalidated') {
return; return;
} }
markGameServerContact();
void refreshMessages(payload.refreshGrant); void refreshMessages(payload.refreshGrant);
}); });
@@ -1182,14 +1188,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
for (const legacyEventType of ['turnCompleted', 'readModelChanged'] as const) { for (const legacyEventType of ['turnCompleted', 'readModelChanged'] as const) {
source.addEventListener(legacyEventType, () => { source.addEventListener(legacyEventType, () => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
markGameServerContact();
realtimeRefreshQueue.request(); realtimeRefreshQueue.request();
}); });
} }
source.addEventListener('messageCreated', () => { source.addEventListener('messageCreated', () => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
markGameServerContact();
void refreshMessages(); void refreshMessages();
}); });
source.addEventListener('ping', () => { source.addEventListener('ping', () => {
markGameServerContact();
if (realtimeEnabled.value) { if (realtimeEnabled.value) {
realtimeStatus.value = 'connected'; realtimeStatus.value = 'connected';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' }); realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' });
@@ -0,0 +1,34 @@
import { readonly, ref, type Ref } from 'vue';
export const GAME_SERVER_ACTIVITY_FRESHNESS_MS = 45_000;
export type GameServerActivityTracker = {
lastContactAt: Readonly<Ref<number | null>>;
markContact: (contactAt?: number) => void;
};
export const createGameServerActivityTracker = (): GameServerActivityTracker => {
const lastContactAt = ref<number | null>(null);
return {
lastContactAt: readonly(lastContactAt),
markContact(contactAt = Date.now()) {
if (!Number.isFinite(contactAt)) return;
lastContactAt.value = contactAt;
},
};
};
export const isRecentGameServerActivity = (
lastContactAt: number | null,
now = Date.now(),
freshnessMs = GAME_SERVER_ACTIVITY_FRESHNESS_MS
): boolean =>
lastContactAt !== null &&
Number.isFinite(lastContactAt) &&
Number.isFinite(now) &&
Math.max(0, now - lastContactAt) <= freshnessMs;
export const gameServerActivity = createGameServerActivityTracker();
export const markGameServerContact = (contactAt = Date.now()) => gameServerActivity.markContact(contactAt);
@@ -0,0 +1,67 @@
export type ServerClockProjectionInput = {
serverTime?: string;
serverWallTime?: string;
clockMode?: 'realtime' | 'manual';
clockRunning?: boolean;
clockStartsAt?: string | null;
};
export type SampledServerClock = {
serverTimeMs: number;
sampledClientTimeMs: number;
clockMode: 'realtime' | 'manual';
startDelayMs: number | null;
};
const parseInstant = (value?: string | null): number | null => {
if (!value) return null;
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : null;
};
export const sampleServerClock = (
input: ServerClockProjectionInput,
sampledClientTimeMs = Date.now()
): SampledServerClock | null => {
const serverTimeMs = parseInstant(input.serverTime);
if (serverTimeMs === null) return null;
let startDelayMs: number | null;
if (input.clockMode === 'manual') {
startDelayMs = null;
} else if (input.clockRunning !== false) {
startDelayMs = 0;
} else {
const serverWallTimeMs = parseInstant(input.serverWallTime);
const clockStartsAtMs = parseInstant(input.clockStartsAt);
startDelayMs =
serverWallTimeMs !== null && clockStartsAtMs !== null
? Math.max(0, clockStartsAtMs - serverWallTimeMs)
: null;
}
return {
serverTimeMs,
sampledClientTimeMs,
clockMode: input.clockMode ?? 'realtime',
startDelayMs,
};
};
export const projectServerClock = (sample: SampledServerClock, clientTimeMs = Date.now()) => {
const clientElapsedMs = Math.max(0, clientTimeMs - sample.sampledClientTimeMs);
const elapsedGameMs =
sample.clockMode === 'manual' || sample.startDelayMs === null
? 0
: Math.max(0, clientElapsedMs - sample.startDelayMs);
return {
clientElapsedMs,
time: new Date(sample.serverTimeMs + elapsedGameMs),
};
};
export const millisecondsUntilNextMinute = (time: Date): number => {
const remainder = ((time.getTime() % 60_000) + 60_000) % 60_000;
return remainder === 0 ? 60_000 : 60_000 - remainder;
};
+6
View File
@@ -3,6 +3,7 @@ import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common/realtime/types';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/game-api'; import type { AppRouter } from '@sammo-ts/game-api';
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant'; import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
import { markGameServerContact } from './gameServerActivity';
const getGameToken = (): string | null => { const getGameToken = (): string | null => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -17,6 +18,11 @@ export const trpc = createTRPCProxyClient<AppRouter>({
httpBatchLink({ httpBatchLink({
url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc', url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc',
...trpcJsonBodyHttpClientOptions, ...trpcJsonBodyHttpClientOptions,
async fetch(input, init) {
const result = await globalThis.fetch(input, init);
markGameServerContact();
return result;
},
headers({ opList }) { headers({ opList }) {
const token = getGameToken(); const token = getGameToken();
const refreshGrant = resolveBatchRealtimeAccessGrant(opList); const refreshGrant = resolveBatchRealtimeAccessGrant(opList);
+9 -1
View File
@@ -243,7 +243,15 @@ watch(
</div> </div>
<div data-main-target="policy"> <div data-main-target="policy">
<MainFrontStatus :status="frontStatus" :tournament-stage="tournamentStage" /> <MainFrontStatus
:status="frontStatus"
:tournament-stage="tournamentStage"
:server-time="lobbyInfo?.serverTime"
:server-wall-time="lobbyInfo?.serverWallTime"
:clock-mode="lobbyInfo?.clockMode"
:clock-running="lobbyInfo?.clockRunning"
:clock-starts-at="lobbyInfo?.clockStartsAt"
/>
</div> </div>
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite"> <aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
@@ -285,7 +285,13 @@ onMounted(() => void loadStratFinan());
</header> </header>
<div class="scout-limit">870px x 200px를 넘어서는 내용은 표시되지 않습니다.</div> <div class="scout-limit">870px x 200px를 넘어서는 내용은 표시되지 않습니다.</div>
<div v-if="!editingScoutMsg" class="message-preview scout-preview" v-html="scoutMsg || '내용 없음'" /> <div v-if="!editingScoutMsg" class="message-preview scout-preview" v-html="scoutMsg || '내용 없음'" />
<LegacyHtmlEditor v-else v-model="scoutMsgDraft" :max-length="1000" aria-label="임관 권유" /> <LegacyHtmlEditor
v-else
v-model="scoutMsgDraft"
class="scout-editor"
:max-length="1000"
aria-label="임관 권유"
/>
</section> </section>
<div class="finance-title">예산&amp;정책</div> <div class="finance-title">예산&amp;정책</div>
@@ -551,6 +557,45 @@ textarea {
margin-left: auto; margin-left: auto;
overflow: hidden; overflow: hidden;
} }
.message-preview :deep(ol),
.message-preview :deep(ul) {
margin: 0 0 0.4em;
padding-left: 2em;
list-style-position: outside;
}
.message-preview :deep(ol) {
list-style-type: decimal;
}
.message-preview :deep(ul) {
list-style-type: disc;
}
.message-preview :deep(li > p) {
margin: 0;
}
.message-preview :deep(img) {
max-width: 100%;
}
.message-preview :deep(img.custom-image-align-left) {
display: block;
margin-right: auto;
margin-left: 0;
}
.message-preview :deep(img.custom-image-align-center) {
display: block;
margin-right: auto;
margin-left: auto;
}
.message-preview :deep(img.custom-image-align-right) {
display: block;
margin-right: 0;
margin-left: auto;
}
.message-preview :deep(img.custom-image-align-float-left) {
float: left;
}
.message-preview :deep(img.custom-image-align-float-right) {
float: right;
}
.finance-grid { .finance-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
GAME_SERVER_ACTIVITY_FRESHNESS_MS,
createGameServerActivityTracker,
isRecentGameServerActivity,
} from '../src/utils/gameServerActivity.ts';
void test('keeps the most recently observed server contact timestamp', () => {
const tracker = createGameServerActivityTracker();
tracker.markContact(2_000);
tracker.markContact(1_000);
tracker.markContact(Number.NaN);
assert.equal(tracker.lastContactAt.value, 1_000);
});
void test('treats three heartbeat intervals as recent activity', () => {
assert.equal(isRecentGameServerActivity(1_000, 1_000 + GAME_SERVER_ACTIVITY_FRESHNESS_MS), true);
assert.equal(isRecentGameServerActivity(1_000, 1_001 + GAME_SERVER_ACTIVITY_FRESHNESS_MS), false);
assert.equal(isRecentGameServerActivity(null, 1_000), false);
});
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
millisecondsUntilNextMinute,
projectServerClock,
sampleServerClock,
} from '../src/utils/serverClockProjection.ts';
void test('projects a running server clock from the browser sample instant', () => {
const sample = sampleServerClock(
{
serverTime: '2026-08-13T00:00:35.250Z',
clockMode: 'realtime',
clockRunning: true,
},
10_000
);
assert.ok(sample);
assert.equal(projectServerClock(sample, 34_750).time.toISOString(), '2026-08-13T00:01:00.000Z');
assert.equal(millisecondsUntilNextMinute(projectServerClock(sample, 10_000).time), 24_750);
});
void test('keeps manual clocks fixed even while client time advances', () => {
const sample = sampleServerClock(
{ serverTime: '2026-08-13T00:00:35.000Z', clockMode: 'manual', clockRunning: false },
10_000
);
assert.ok(sample);
assert.equal(projectServerClock(sample, 130_000).time.toISOString(), '2026-08-13T00:00:35.000Z');
});
void test('holds a preopen clock until its wall-clock start delay passes', () => {
const sample = sampleServerClock(
{
serverTime: '2026-08-13T00:00:00.000Z',
serverWallTime: '2026-08-13T08:00:00.000Z',
clockMode: 'realtime',
clockRunning: false,
clockStartsAt: '2026-08-13T08:01:00.000Z',
},
10_000
);
assert.ok(sample);
assert.equal(projectServerClock(sample, 69_999).time.toISOString(), '2026-08-13T00:00:00.000Z');
assert.equal(projectServerClock(sample, 70_001).time.toISOString(), '2026-08-13T00:00:00.001Z');
});
void test('rejects an invalid server clock sample', () => {
assert.equal(sampleServerClock({ serverTime: 'not-a-time' }, 10_000), null);
});
@@ -1,4 +1,5 @@
import { createGatewayPostgresConnector } from '@sammo-ts/infra'; import { createGatewayPostgresConnector } from '@sammo-ts/infra';
import { randomUUID } from 'node:crypto';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayReleaseRepository } from '../src/orchestrator/gatewayReleaseRepository.js'; import { createGatewayReleaseRepository } from '../src/orchestrator/gatewayReleaseRepository.js';
@@ -115,4 +116,41 @@ describeDatabase('gateway release operation persistence', () => {
}); });
await expect(repository.renewOperationLease(operation.id, 'controller-a', now, 1_000)).resolves.toBe(false); await expect(repository.renewOperationLease(operation.id, 'controller-a', now, 1_000)).resolves.toBe(false);
}); });
it('stores direct SQL defaults as the same instant in a Seoul database session', async () => {
const operationId = randomUUID();
const beforeInsert = Date.now();
const [session] = await connector.prisma.$queryRaw<Array<{ timezone: string }>>`
SELECT current_setting('TimeZone') AS "timezone"
`;
expect(session?.timezone).toBe('UTC');
await connector.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SET LOCAL TIME ZONE 'Asia/Seoul'`;
await tx.$executeRaw`
INSERT INTO "gateway_release_operation" (
"id", "type", "status", "source_mode", "source_ref", "payload",
"requested_by", "attempts", "updated_at"
) VALUES (
${operationId}, 'DEPLOY', 'QUEUED', 'BRANCH', 'main', '{}'::jsonb,
'direct-sql-test', 0, CURRENT_TIMESTAMP
)
`;
await tx.$executeRaw`
INSERT INTO "gateway_release_log" ("operation_id", "level", "phase", "message")
VALUES (${operationId}, 'INFO', 'queue', 'direct SQL timestamp test')
`;
});
const afterInsert = Date.now();
const operation = await repository.getOperation(operationId);
const [log] = await repository.listOperationLogs(operationId);
expect(operation).toBeDefined();
const createdAt = Date.parse(operation?.createdAt ?? '');
const updatedAt = Date.parse(operation?.updatedAt ?? '');
expect(createdAt).toBeGreaterThanOrEqual(beforeInsert);
expect(createdAt).toBeLessThanOrEqual(afterInsert);
expect(updatedAt).toBeGreaterThanOrEqual(beforeInsert);
expect(updatedAt).toBeLessThanOrEqual(afterInsert);
expect(log?.createdAt).toBe(operation?.createdAt);
});
}); });
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260819000000_backfill_profile_release_source', gatewaySchemaHead: '20260821173000_gateway_release_instant_timestamps',
gameSchemaHead: '20260820002000_persist_official_game_index', gameSchemaHead: '20260820002000_persist_official_game_index',
}); });
}); });
+12
View File
@@ -292,6 +292,18 @@ Gateway process definition에는 `GATEWAY_DATABASE_URL`과 `REDIS_URL`이 모두
Gateway 전체에는 활성 릴리스 작업을 동시에 하나만 둘 수 있습니다. 화면의 Gateway 전체에는 활성 릴리스 작업을 동시에 하나만 둘 수 있습니다. 화면의
릴리스 이력에서 요청 source, 고정 commit, 상태와 오류를 확인할 수 있습니다. 릴리스 이력에서 요청 source, 고정 commit, 상태와 오류를 확인할 수 있습니다.
릴리스 이력의 생성·시작·완료·로그 시각은 모두 PostgreSQL `timestamptz` instant로
저장합니다. 운영 PostgreSQL session이 `Asia/Seoul`이어도 `CURRENT_TIMESTAMP`는 같은
실제 instant를 저장하며, 화면에서만 고정 UTC+9 서버 시각으로 투영합니다. 기존
`timestamp without time zone` 값을 이관할 때는 raw 값을 UTC로 해석하여 그대로
보존하므로 과거에 잘못 들어간 행을 소급 보정하지 않습니다.
관리자 session을 통한 API 요청을 우선합니다. 명시적 운영 권한 아래 durable queue를
직접 등록해야 하는 예외 상황에도 KST 벽시계 문자열이나 timezone 없는 문자열을
`created_at`에 직접 만들지 말고 column default 또는 timezone-aware instant를
사용합니다. `now()`를 timezone 없는 열에 쓰는 방식은 DB session이 KST일 때 화면에서
다시 UTC+9가 적용되어 생성 시각만 9시간 미래가 될 수 있습니다.
작업을 선택하면 관리자 화면이 `admin.releases.logs`를 최대 20초씩 long polling하여 작업을 선택하면 관리자 화면이 `admin.releases.logs`를 최대 20초씩 long polling하여
commit 해석, worktree 준비, build 명령 출력, migration, process 전환, commit 해석, worktree 준비, build 명령 출력, migration, process 전환,
readiness와 rollback 진행을 커서 순서대로 이어 붙입니다. 완료된 작업의 로그도 readiness와 rollback 진행을 커서 순서대로 이어 붙입니다. 완료된 작업의 로그도
@@ -0,0 +1,34 @@
-- Gateway release history is an absolute control-plane timeline. Preserve every
-- existing raw value as its current UTC interpretation (including historical bad
-- rows), then make future CURRENT_TIMESTAMP writes independent of session timezone.
ALTER TABLE "gateway_release_operation"
ALTER COLUMN "created_at" DROP DEFAULT;
ALTER TABLE "gateway_release_log"
ALTER COLUMN "created_at" DROP DEFAULT;
ALTER TABLE "gateway_release_state"
ALTER COLUMN "last_successful_at" TYPE TIMESTAMPTZ(3)
USING "last_successful_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3)
USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "gateway_release_operation"
ALTER COLUMN "started_at" TYPE TIMESTAMPTZ(3)
USING "started_at" AT TIME ZONE 'UTC',
ALTER COLUMN "completed_at" TYPE TIMESTAMPTZ(3)
USING "completed_at" AT TIME ZONE 'UTC',
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3)
USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3)
USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "gateway_release_log"
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3)
USING "created_at" AT TIME ZONE 'UTC';
ALTER TABLE "gateway_release_operation"
ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "gateway_release_log"
ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;
+9 -9
View File
@@ -315,9 +315,9 @@ model GatewayReleaseState {
activeWorkspace String? @map("active_workspace") activeWorkspace String? @map("active_workspace")
previousCommitSha String? @map("previous_commit_sha") previousCommitSha String? @map("previous_commit_sha")
previousWorkspace String? @map("previous_workspace") previousWorkspace String? @map("previous_workspace")
lastSuccessfulAt DateTime? @map("last_successful_at") lastSuccessfulAt DateTime? @map("last_successful_at") @db.Timestamptz(3)
lastError String? @map("last_error") lastError String? @map("last_error")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@map("gateway_release_state") @@map("gateway_release_state")
} }
@@ -334,15 +334,15 @@ model GatewayReleaseOperation {
payload Json @default(dbgenerated("'{}'::jsonb")) payload Json @default(dbgenerated("'{}'::jsonb"))
reason String? reason String?
requestedBy String @map("requested_by") requestedBy String @map("requested_by")
startedAt DateTime? @map("started_at") startedAt DateTime? @map("started_at") @db.Timestamptz(3)
completedAt DateTime? @map("completed_at") completedAt DateTime? @map("completed_at") @db.Timestamptz(3)
error String? error String?
leaseOwner String? @map("lease_owner") leaseOwner String? @map("lease_owner")
leaseUntil DateTime? @map("lease_until") leaseUntil DateTime? @map("lease_until") @db.Timestamptz(6)
heartbeatAt DateTime? @map("heartbeat_at") heartbeatAt DateTime? @map("heartbeat_at") @db.Timestamptz(6)
attempts Int @default(0) attempts Int @default(0)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
logs GatewayReleaseLog[] logs GatewayReleaseLog[]
@@index([status, leaseUntil, createdAt]) @@index([status, leaseUntil, createdAt])
@@ -356,7 +356,7 @@ model GatewayReleaseLog {
level String level String
phase String phase String
message String @db.Text message String @db.Text
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
operation GatewayReleaseOperation @relation(fields: [operationId], references: [id], onDelete: Cascade) operation GatewayReleaseOperation @relation(fields: [operationId], references: [id], onDelete: Cascade)
@@index([operationId, id]) @@index([operationId, id])
+1 -1
View File
@@ -6,4 +6,4 @@ import type { PostgresConfig, PostgresConnector } from './postgres.js';
import { createPostgresConnector } from './postgres.js'; import { createPostgresConnector } from './postgres.js';
export const createGatewayPostgresConnector = (config: PostgresConfig): PostgresConnector<GatewayPrismaClient> => export const createGatewayPostgresConnector = (config: PostgresConfig): PostgresConnector<GatewayPrismaClient> =>
createPostgresConnector(config, (options) => new GatewayPrismaClient(options)); createPostgresConnector({ ...config, sessionTimezone: 'UTC' }, (options) => new GatewayPrismaClient(options));
+18 -6
View File
@@ -14,6 +14,7 @@ export interface PostgresConfig {
url: string; url: string;
log?: PostgresLogOption[]; log?: PostgresLogOption[];
maxConnections?: number; maxConnections?: number;
sessionTimezone?: 'UTC';
} }
export interface PostgresPoolStats { export interface PostgresPoolStats {
@@ -60,21 +61,32 @@ interface SharedPoolEntry {
const sharedPools = new Map<string, SharedPoolEntry>(); const sharedPools = new Map<string, SharedPoolEntry>();
const buildSharedPoolKey = (url: string, schema: string | undefined, maxConnections: number): string => const buildSharedPoolKey = (
JSON.stringify([url, schema ?? '', maxConnections]); url: string,
schema: string | undefined,
maxConnections: number,
sessionTimezone: 'UTC' | undefined
): string => JSON.stringify([url, schema ?? '', maxConnections, sessionTimezone ?? '']);
const acquireSharedPool = ( const acquireSharedPool = (
url: string, url: string,
schema: string | undefined, schema: string | undefined,
maxConnections: number maxConnections: number,
sessionTimezone: 'UTC' | undefined
): { entry: SharedPoolEntry; release: () => Promise<void> } => { ): { entry: SharedPoolEntry; release: () => Promise<void> } => {
const key = buildSharedPoolKey(url, schema, maxConnections); const key = buildSharedPoolKey(url, schema, maxConnections, sessionTimezone);
let entry = sharedPools.get(key); let entry = sharedPools.get(key);
if (!entry) { if (!entry) {
const connectionOptions = [
schema ? `-c search_path=${schema}` : undefined,
sessionTimezone ? `-c timezone=${sessionTimezone}` : undefined,
]
.filter((option): option is string => option !== undefined)
.join(' ');
const pool = new pg.Pool({ const pool = new pg.Pool({
connectionString: url, connectionString: url,
max: maxConnections, max: maxConnections,
...(schema ? { options: `-c search_path=${schema}` } : {}), ...(connectionOptions ? { options: connectionOptions } : {}),
}); });
entry = { pool, references: 0, maxConnections }; entry = { pool, references: 0, maxConnections };
sharedPools.set(key, entry); sharedPools.set(key, entry);
@@ -161,7 +173,7 @@ export const createPostgresConnector = <TClient>(
const schema = const schema =
extractSchemaFromDatabaseUrl(config.url) ?? process.env.POSTGRES_SCHEMA ?? process.env.DATABASE_SCHEMA; extractSchemaFromDatabaseUrl(config.url) ?? process.env.POSTGRES_SCHEMA ?? process.env.DATABASE_SCHEMA;
const maxConnections = resolvePostgresPoolMax(config.maxConnections ?? process.env.POSTGRES_POOL_MAX); const maxConnections = resolvePostgresPoolMax(config.maxConnections ?? process.env.POSTGRES_POOL_MAX);
const sharedPool = acquireSharedPool(config.url, schema, maxConnections); const sharedPool = acquireSharedPool(config.url, schema, maxConnections, config.sessionTimezone);
const adapter = new PrismaPg(sharedPool.entry.pool, schema ? { schema } : undefined); const adapter = new PrismaPg(sharedPool.entry.pool, schema ? { schema } : undefined);
const prisma = createClient({ const prisma = createClient({
adapter, adapter,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20260819000000_backfill_profile_release_source", "gatewaySchemaHead": "20260821173000_gateway_release_instant_timestamps",
"gameSchemaHead": "20260820002000_persist_official_game_index", "gameSchemaHead": "20260820002000_persist_official_game_index",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
} }