11 changed files with 274 additions and 7 deletions

No files matched your search

+8 -1
View File
@@ -1079,6 +1079,7 @@ export const createDatabaseTurnHooks = async (
profileName?: string;
reservedTurns?: InMemoryReservedTurnStore;
turnDaemonLease?: DatabaseTurnDaemonLease;
transactionMaxWaitMs?: number;
transactionTimeoutMs?: number;
}
): Promise<DatabaseTurnHooks> => {
@@ -1089,7 +1090,13 @@ export const createDatabaseTurnHooks = async (
// Prisma's 5-second default matches the normal turn execution budget too
// closely. A populated season can finish the turn but expire while flushing
// it, which rolls the transaction back and marks the profile PAUSED.
const transactionOptions = { timeout: options?.transactionTimeoutMs ?? 30_000 };
// 실행 timeout과 연결 획득 대기는 별도 제한이다. 기본 maxWait 2초는
// 일시적인 DB 지연에도 callback 시작 전에 턴을 영구 PAUSED로 만들었다.
// 대기를 유한하게 늘리되 callback 재실행이나 lease/fencing 우회는 하지 않는다.
const transactionOptions = {
maxWait: options?.transactionMaxWaitMs ?? 10_000,
timeout: options?.transactionTimeoutMs ?? 30_000,
};
const readModelBaseline = createRealtimeReadModelBaseline(world);
let worldReadModelBaseline = createWorldReadModelSignature(world);
let persistedTickSeconds = world.getState().tickSeconds;
+2
View File
@@ -139,6 +139,7 @@ export interface TurnDaemonRuntimeOptions {
* loaded once and no concurrent API writer may touch this database.
*/
exclusiveFastForward?: boolean;
databaseTransactionMaxWaitMs?: number;
databaseTransactionTimeoutMs?: number;
onActionResolved?: NonNullable<Parameters<typeof createReservedTurnHandler>[0]['onActionResolved']>;
}
@@ -933,6 +934,7 @@ const createTurnDaemonRuntimeWithLease = async (
profileName: options.profileName ?? options.profile,
reservedTurns: reservedTurnStoreHandle?.store,
turnDaemonLease: turnDaemonLease ?? undefined,
transactionMaxWaitMs: options.databaseTransactionMaxWaitMs,
transactionTimeoutMs: options.databaseTransactionTimeoutMs,
});
try {
@@ -135,3 +135,106 @@ integration('turn failure personal-record persistence', () => {
]);
});
});
integration('turn persistence connection acquisition', () => {
let connector: ReturnType<typeof createGamePostgresConnector>;
let hooks: DatabaseTurnHooks;
const fixtureWorldId = worldId + 10;
beforeAll(async () => {
connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
await connector.prisma.worldState.create({
data: {
id: fixtureWorldId,
scenarioCode: 'transaction-acquisition',
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
clockBaseTime: turnTime,
clockWallAnchor: turnTime,
clockMode: 'manual',
clockPhase: 'MANUAL',
clockTick: 0n,
lastTurnTick: 0n,
},
});
hooks = await createDatabaseTurnHooks(
databaseUrl!,
new InMemoryTurnWorld({ ...state, id: fixtureWorldId }, snapshot, { schedule }),
{ transactionTimeoutMs: 1_000 }
);
});
afterAll(async () => {
await hooks?.close();
await connector?.prisma.worldState.deleteMany({ where: { id: fixtureWorldId } });
await connector?.disconnect();
});
const occupyPool = async () => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const started: Promise<void>[] = [];
const blockers = Array.from({ length: connector.getPoolStats().max }, () => {
let markStarted!: () => void;
started.push(new Promise<void>((resolve) => (markStarted = resolve)));
return connector.prisma.$transaction(
async (tx) => {
await tx.$queryRaw`SELECT 1`;
markStarted();
await held;
},
{ maxWait: 10_000, timeout: 20_000 }
);
});
// Attach handlers immediately so a setup failure cannot leak transactions.
const settled = Promise.allSettled(blockers);
try {
await Promise.all(started);
} catch (error) {
release();
await settled;
throw error;
}
return { release, settled };
};
it('waits beyond Prisma default without spending the callback execution budget', async () => {
const held = await occupyPool();
const sync = hooks.synchronizeClockAuthority();
// Observe the result immediately; the old 2-second default rejects here.
const outcome = sync.then(
(value) => ({ ok: true, value }),
(error: unknown) => ({ ok: false, error })
);
try {
const deadline = Date.now() + 1_000;
while (!connector.getPoolStats().waiting && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(connector.getPoolStats().waiting).toBeGreaterThan(0);
await new Promise((resolve) => setTimeout(resolve, 2_500));
held.release();
expect(await outcome).toMatchObject({ ok: true });
} finally {
held.release();
await held.settled;
await outcome;
}
}, 15_000);
it('still rejects sustained connection exhaustion at the finite acquisition limit', async () => {
const held = await occupyPool();
try {
await expect(hooks.synchronizeClockAuthority()).rejects.toMatchObject({ code: 'P2028' });
} finally {
held.release();
await held.settled;
}
// Timed-out acquisition must not prevent the next normal operation.
await expect(hooks.synchronizeClockAuthority()).resolves.toBeTypeOf('boolean');
}, 15_000);
});
@@ -710,6 +710,106 @@ test('trait and injury explanations appear on pointer hover and keyboard focus',
await page.screenshot({ path: testInfo.outputPath('directory-trait-injury-tooltips.png'), fullPage: true });
});
for (const width of [1200, 500]) {
test(`catalog description line breaks stay safe on hover and focus at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
const warInfo =
'[계략] 화계·탈취·파괴·선동 : 성공률 +10%p<br>[전투] 계략 시도 확률 +20%p, 계략 성공 확률 +20%p';
const literalMarkup = '<img src=x onerror="alert(1)"><b>설명</b>';
const directoryRows = generals.map((general) =>
general.id === 10
? {
...general,
specialDomestic: {
...general.specialDomestic,
info: `상업 특기<br/>수익 증가<BR />매매 효율\r\n${literalMarkup}`,
},
specialWar: { key: 'che_신산', name: '신산', info: warInfo },
}
: general
);
await install(page, 'general', [], [], directoryRows);
await page.goto('general-list');
await page.reload();
const prefix = width <= 600 ? 'card-' : '';
const container = page
.locator(width <= 600 ? '[data-general-card-id="10"]' : 'tr[data-general-id="10"]')
.first();
await expect(container).toBeVisible();
await expect(page.getByRole('button', { name: '갱 신' })).not.toHaveAttribute('aria-busy');
await page.evaluate(async () => {
await document.fonts.ready;
await Promise.all(Array.from(document.images, (image) => image.decode().catch(() => undefined)));
});
const before = await container.boundingBox();
const evidence: Record<string, unknown> = { width, url: page.url(), before };
for (const [kind, expected] of [
['special-domestic', ['상업 특기', '수익 증가', '매매 효율', literalMarkup]],
['special-war', warInfo.split('<br>')],
] as const) {
const trigger = page.locator(`[data-directory-tooltip="${prefix}${kind}-10"]`);
const tooltip = trigger.getByRole('tooltip');
await page.mouse.move(0, 0);
await trigger.hover();
await expect(tooltip).toBeVisible();
await trigger.focus();
await expect(tooltip).toBeVisible();
await expect(trigger).toHaveCSS('text-decoration-line', 'none');
await expect(trigger).toHaveCSS('outline-style', 'solid');
const description = tooltip.locator('.directory-tooltip__description');
await expect(description.locator('br')).toHaveCount(expected.length - 1);
await expect(description.locator('img, b, script')).toHaveCount(0);
expect(await description.innerText()).not.toMatch(/<br\s*\/?\s*>/iu);
const metrics = await description.evaluate((element) => {
const lines = Array.from(element.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
);
return {
lines: lines.map((node) => {
const range = document.createRange();
range.selectNodeContents(node);
return { text: node.textContent?.trim(), top: range.getBoundingClientRect().top };
}),
html: element.outerHTML,
};
});
expect(metrics.lines.map((line) => line.text)).toEqual(expected);
for (let index = 1; index < metrics.lines.length; index += 1) {
expect(metrics.lines[index]!.top).toBeGreaterThan(metrics.lines[index - 1]!.top);
}
const geometry = await tooltip.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
font: style.font,
background: style.backgroundColor,
position: style.position,
};
});
expect(geometry.left).toBeGreaterThanOrEqual(8);
expect(geometry.right).toBeLessThanOrEqual(width - 8);
expect(geometry.top).toBeGreaterThanOrEqual(0);
expect(geometry.bottom).toBeLessThanOrEqual(900);
expect(await container.boundingBox()).toEqual(before);
evidence[kind] = { ...metrics, geometry };
await page.screenshot({ path: testInfo.outputPath(`catalog-${kind}-${width}.png`), fullPage: true });
await trigger.blur();
}
const empty = page.locator(`[data-directory-tooltip="${prefix}special-war-20"]`);
await expect(empty).not.toHaveAttribute('tabindex');
await expect(empty.getByRole('tooltip')).toHaveCount(0);
await writeFile(testInfo.outputPath('catalog-description.json'), JSON.stringify(evidence, null, 2));
await writeFile(
testInfo.outputPath('catalog-description.html'),
await page.locator('main').evaluate((element) => element.outerHTML)
);
});
}
test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => {
await install(page);
await page.goto('npc-list');
@@ -1,9 +1,14 @@
<script setup lang="ts">
defineProps<{
import { computed } from 'vue';
import { splitLegacyLineBreaks } from '../../utils/legacyDescription';
const props = defineProps<{
title: string;
description?: string | null;
testId?: string;
}>();
const descriptionLines = computed(() => splitLegacyLineBreaks(props.description ?? ''));
</script>
<template>
@@ -16,7 +21,10 @@ defineProps<{
<slot />
<span v-if="description" class="directory-tooltip__content" role="tooltip">
<strong>{{ title }}</strong>
<span>{{ description }}</span>
<span class="directory-tooltip__description"
><template v-for="(line, index) in descriptionLines" :key="index"
>{{ line }}<br v-if="index < descriptionLines.length - 1" /></template
></span>
</span>
</span>
</template>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue';
import { splitLegacyLineBreaks } from '../../utils/legacyDescription';
import tippy, { type Instance, type Placement } from 'tippy.js';
import 'tippy.js/dist/tippy.css';
@@ -28,7 +29,7 @@ let instance: Instance | null = null;
const descriptionLines = computed(() => {
const source = Array.isArray(props.description) ? props.description : [props.description ?? ''];
return source
.flatMap((line) => line.split(/<br\s*\/?\s*>|\r?\n/giu))
.flatMap(splitLegacyLineBreaks)
.map((line) => line.trim())
.filter(Boolean);
});
@@ -0,0 +1,5 @@
/**
* 카탈로그 설명의 Ref 줄바꿈만 해석한다. 반환값은 반드시 text interpolation으로
* 렌더링하며 HTML로 삽입하지 않는다. 빈 줄과 공백의 표시 정책은 소비자가 정한다.
*/
export const splitLegacyLineBreaks = (value: string): string[] => value.split(/<br\s*\/?\s*>|\r\n?|\n/giu);
+1 -1
View File
@@ -4,6 +4,7 @@ import { usePageExit } from '../composables/usePageExit';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import { computed, onMounted, reactive, ref } from 'vue';
import { splitLegacyLineBreaks } from '../utils/legacyDescription';
import { trpc } from '../utils/trpc';
import { useGameFeedback } from '../composables/useGameFeedback';
@@ -207,7 +208,6 @@ const specialNameMap = computed(() => {
}
return map;
});
const splitLegacyLineBreaks = (value: string): string[] => value.split(/<br\s*\/?\s*>|\r?\n/giu);
const selectedSpecialWarInfo = computed(
() => status.value?.availableSpecialWar.find((entry) => entry.key === nextSpecialKey.value)?.info ?? ''
);
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { splitLegacyLineBreaks } from '../src/utils/legacyDescription.ts';
void describe('legacy catalog description line breaks', () => {
void it('accepts catalog br variants and platform newlines', () => {
assert.deepEqual(splitLegacyLineBreaks('가<br>나<BR/>다<br />라\r\n마\n바\r사'), [
'가',
'나',
'다',
'라',
'마',
'바',
'사',
]);
});
void it('preserves empty lines, whitespace, and unformatted descriptions', () => {
assert.deepEqual(splitLegacyLineBreaks(''), ['']);
assert.deepEqual(splitLegacyLineBreaks(' 일반 설명 '), [' 일반 설명 ']);
assert.deepEqual(splitLegacyLineBreaks('<br>첫 줄<br><br>끝 줄\n'), ['', '첫 줄', '', '끝 줄', '']);
});
void it('leaves other markup, attributes, and entities as literal text', () => {
const text = '<img src=x onerror="alert(1)"><b>문구</b>&lt;br&gt;<br onclick="alert(1)">';
assert.deepEqual(splitLegacyLineBreaks(text), [text]);
});
});
+8
View File
@@ -41,6 +41,14 @@ tooltip trigger는 hover/focus 동작과 `cursor: help`를 유지하되, tooltip
텍스트에 점선 밑줄을 반복하지 않습니다. keyboard `focus-visible` outline은 별도의
접근성 상태로 유지합니다.
카탈로그 설명의 줄바꿈은 `src/utils/legacyDescription.ts`의
`splitLegacyLineBreaks()`가 해석합니다. `DirectoryTooltip`, `RichTooltip`, 유산의
특기·유니크 설명은 `<br>`, `<br/>`, `<br />`(대소문자 무관)와 실제 개행을 이 함수로
나누고 각 줄을 Vue text interpolation으로 렌더링합니다. 다른 HTML이나 entity는
텍스트로 유지합니다. 일람·유산은 빈 줄을 보존하며, `RichTooltip`은 기존대로 각 줄의
앞뒤 공백과 빈 줄을 제거합니다. 일람의 표와 모바일 카드에는 같은 tooltip을 쓰며
줄바꿈 때문에 trigger의 geometry나 많은 행을 위한 lazy 구조를 바꾸지 않습니다.
## Button composition
Choose the Ref visual family before choosing a semantic color. Buttons from
+7 -2
View File
@@ -363,7 +363,13 @@ Hyper-V의 VM 정지/재개, 체크포인트 또는 host 시간 보정은 게스
NTP 로그와 Windows Hyper-V/백업 작업 이력을 대조합니다. lease 기간을 늘리거나
시간 동기화를 임의로 끄는 것으로 해결하지 않습니다.
Turn daemon의 DB persistence interactive transaction은 기본 30초입니다. 정상 turn
Turn daemon의 DB persistence interactive transaction은 연결 획득 대기(`maxWait`)
10초와 callback 실행(`timeout`) 30초를 각각 제한합니다. Prisma 기본 연결 대기
2초는 일시적인 DB 지연에도 callback 시작 전 `P2028`로 서버를 PAUSED로 만들 수
있습니다. 대기 상한을 넘거나 실행·lease 검증이 실패하면 기존 오류 처리를 유지하며,
callback을 자동 재실행하지 않습니다. 내부 runtime 설정의
`databaseTransactionMaxWaitMs`와 `databaseTransactionTimeoutMs`로 각각 조정합니다.
정상 turn
budget과 같은 Prisma 기본 5초를 그대로 쓰면 populated season의 flush가 경계에서
rollback되고 profile이 `PAUSED`로 전환될 수 있습니다. timeout을 늘려도 한
transaction의 계산·RNG·write 순서는 바뀌지 않지만 lock 보유 상한도 함께 늘어나므로
@@ -751,7 +757,6 @@ NDJSON frame 512 KiB/stream 64 MiB, 느린 소비자의 write buffer 1 MiB를
release를 보존합니다. 외부 공개 stack과 임시 localhost stack의 token 파일은
공유하지 않습니다. 임시 host의 절차는 상위 local-runtime README를 따릅니다.
### 이미지 업로드와 WebPush 전송 경계
게시판 이미지의 기존 1 MiB 입력·2048px 변환 정책, AVIF 원본 보존과 WebP 변환,