merge: 최신 main을 레거시 DB 재이관에 통합
# Conflicts: # app/gateway-api/test/releaseManifest.test.ts # release-manifest.json
This commit is contained in:
@@ -71,6 +71,13 @@ const zParticipant = z.object({
|
|||||||
gl: z.number().int().optional(),
|
gl: z.number().int().optional(),
|
||||||
seedRank: z.number().int().optional(),
|
seedRank: z.number().int().optional(),
|
||||||
finalRank: z.number().int().optional(),
|
finalRank: z.number().int().optional(),
|
||||||
|
preliminaryGroupId: z.number().int().min(0).max(7).optional(),
|
||||||
|
preliminaryGroupNo: z.number().int().min(0).max(7).optional(),
|
||||||
|
preliminaryRank: z.number().int().min(1).max(8).optional(),
|
||||||
|
preliminaryWin: z.number().int().min(0).optional(),
|
||||||
|
preliminaryDraw: z.number().int().min(0).optional(),
|
||||||
|
preliminaryLose: z.number().int().min(0).optional(),
|
||||||
|
preliminaryGl: z.number().int().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const zMatch = z.object({
|
const zMatch = z.object({
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ export interface TournamentParticipantEntry {
|
|||||||
gl?: number;
|
gl?: number;
|
||||||
seedRank?: number;
|
seedRank?: number;
|
||||||
finalRank?: number;
|
finalRank?: number;
|
||||||
|
preliminaryGroupId?: number;
|
||||||
|
preliminaryGroupNo?: number;
|
||||||
|
preliminaryRank?: number;
|
||||||
|
preliminaryWin?: number;
|
||||||
|
preliminaryDraw?: number;
|
||||||
|
preliminaryLose?: number;
|
||||||
|
preliminaryGl?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TournamentMatchEntry {
|
export interface TournamentMatchEntry {
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ export const applyPreBattleStage = async (
|
|||||||
gl: 0,
|
gl: 0,
|
||||||
seedRank: 0,
|
seedRank: 0,
|
||||||
finalRank: 0,
|
finalRank: 0,
|
||||||
|
preliminaryGroupId: entry.groupId,
|
||||||
|
preliminaryGroupNo: entry.groupNo,
|
||||||
}));
|
}));
|
||||||
await store.setParticipants(grouped);
|
await store.setParticipants(grouped);
|
||||||
const nextState: TournamentState = {
|
const nextState: TournamentState = {
|
||||||
@@ -244,10 +246,17 @@ export const applyPreBattleStage = async (
|
|||||||
for (let groupId = 0; groupId < 8; groupId += 1) {
|
for (let groupId = 0; groupId < 8; groupId += 1) {
|
||||||
const groupEntries = ranked.filter((entry) => entry.groupId === groupId);
|
const groupEntries = ranked.filter((entry) => entry.groupId === groupId);
|
||||||
const ordered = sortByRanking(groupEntries);
|
const ordered = sortByRanking(groupEntries);
|
||||||
ordered.slice(0, 4).forEach((entry, idx) => {
|
ordered.forEach((entry, idx) => {
|
||||||
const target = ranked.find((item) => item.id === entry.id);
|
const target = ranked.find((item) => item.id === entry.id);
|
||||||
if (target) {
|
if (target) {
|
||||||
target.seedRank = idx + 1;
|
target.preliminaryGroupId = groupId;
|
||||||
|
target.preliminaryGroupNo = entry.groupNo;
|
||||||
|
target.preliminaryRank = idx + 1;
|
||||||
|
target.preliminaryWin = entry.win ?? 0;
|
||||||
|
target.preliminaryDraw = entry.draw ?? 0;
|
||||||
|
target.preliminaryLose = entry.lose ?? 0;
|
||||||
|
target.preliminaryGl = entry.gl ?? 0;
|
||||||
|
target.seedRank = idx < 4 ? idx + 1 : 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,17 +195,20 @@ export const assignManualApplicantGroup = (options: {
|
|||||||
extraSeed: `manual-group:${options.current.map((entry) => entry.id).join('-')}:${openGroupIds.join('-')}`,
|
extraSeed: `manual-group:${options.current.map((entry) => entry.id).join('-')}:${openGroupIds.join('-')}`,
|
||||||
});
|
});
|
||||||
const groupId = rng.choice(openGroupIds);
|
const groupId = rng.choice(openGroupIds);
|
||||||
|
const groupNo = groupCounts[groupId] ?? 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...options.applicant,
|
...options.applicant,
|
||||||
groupId,
|
groupId,
|
||||||
groupNo: groupCounts[groupId] ?? 0,
|
groupNo,
|
||||||
win: 0,
|
win: 0,
|
||||||
draw: 0,
|
draw: 0,
|
||||||
lose: 0,
|
lose: 0,
|
||||||
gl: 0,
|
gl: 0,
|
||||||
seedRank: 0,
|
seedRank: 0,
|
||||||
finalRank: 0,
|
finalRank: 0,
|
||||||
|
preliminaryGroupId: groupId,
|
||||||
|
preliminaryGroupNo: groupNo,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
||||||
const summary = await caller.tournament.getBettingSummary();
|
const summary = await caller.tournament.getBettingSummary();
|
||||||
expect(summary.myAmount).toBe(600);
|
expect(summary.myAmount).toBe(600);
|
||||||
|
expect(Object.values(summary.myTotals)).toEqual([600]);
|
||||||
expect(summary.totalAmount).toBe(600);
|
expect(summary.totalAmount).toBe(600);
|
||||||
expect(transport.gold.get(general.id)).toBe(2_400);
|
expect(transport.gold.get(general.id)).toBe(2_400);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -246,8 +246,9 @@ describe('tournament worker schedule compatibility', () => {
|
|||||||
groupNo,
|
groupNo,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const groupCounts = Array.from({ length: 8 }, (_, groupId) =>
|
const groupCounts = Array.from(
|
||||||
current.filter((entry) => entry.groupId === groupId).length
|
{ length: 8 },
|
||||||
|
(_, groupId) => current.filter((entry) => entry.groupId === groupId).length
|
||||||
);
|
);
|
||||||
const openGroupId = groupCounts.findIndex((count) => count === 7);
|
const openGroupId = groupCounts.findIndex((count) => count === 7);
|
||||||
expect(openGroupId).toBeGreaterThanOrEqual(0);
|
expect(openGroupId).toBeGreaterThanOrEqual(0);
|
||||||
@@ -267,7 +268,16 @@ describe('tournament worker schedule compatibility', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(applicant).toMatchObject({ groupId: openGroupId, groupNo: 7, win: 0, draw: 0, lose: 0, gl: 0 });
|
expect(applicant).toMatchObject({
|
||||||
|
groupId: openGroupId,
|
||||||
|
groupNo: 7,
|
||||||
|
preliminaryGroupId: openGroupId,
|
||||||
|
preliminaryGroupNo: 7,
|
||||||
|
win: 0,
|
||||||
|
draw: 0,
|
||||||
|
lose: 0,
|
||||||
|
gl: 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('catches up from the stored schedule instead of discarding elapsed legacy phases', () => {
|
it('catches up from the stored schedule instead of discarding elapsed legacy phases', () => {
|
||||||
@@ -334,6 +344,7 @@ describe('tournament worker (in-memory)', () => {
|
|||||||
expect(entries.map((entry) => entry.groupNo).sort((a, b) => Number(a) - Number(b))).toEqual([
|
expect(entries.map((entry) => entry.groupNo).sort((a, b) => Number(a) - Number(b))).toEqual([
|
||||||
0, 1, 2, 3, 4, 5, 6, 7,
|
0, 1, 2, 3, 4, 5, 6, 7,
|
||||||
]);
|
]);
|
||||||
|
expect(entries.every((entry) => entry.preliminaryGroupId === groupId)).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -648,14 +659,30 @@ describe('tournament worker (in-memory)', () => {
|
|||||||
expect(participants.find((entry) => entry.id === 1)).toMatchObject({ groupId: expect.any(Number) });
|
expect(participants.find((entry) => entry.id === 1)).toMatchObject({ groupId: expect.any(Number) });
|
||||||
expect(participants.find((entry) => entry.id === 1001)).toMatchObject({ groupId: expect.any(Number) });
|
expect(participants.find((entry) => entry.id === 1001)).toMatchObject({ groupId: expect.any(Number) });
|
||||||
expect(
|
expect(
|
||||||
Array.from({ length: 8 }, (_, groupId) =>
|
Array.from({ length: 8 }, (_, groupId) => participants.filter((entry) => entry.groupId === groupId).length)
|
||||||
participants.filter((entry) => entry.groupId === groupId).length
|
|
||||||
)
|
|
||||||
).toEqual(Array.from({ length: 8 }, () => 8));
|
).toEqual(Array.from({ length: 8 }, () => 8));
|
||||||
|
|
||||||
await store.setState(afterJoin);
|
await store.setState(afterJoin);
|
||||||
const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' });
|
const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' });
|
||||||
|
const finalParticipants = await store.getParticipants();
|
||||||
expect(finalState.stage).toBe(0);
|
expect(finalState.stage).toBe(0);
|
||||||
expect(finalState.winnerId).toBeDefined();
|
expect(finalState.winnerId).toBeDefined();
|
||||||
|
for (let groupId = 0; groupId < 8; groupId += 1) {
|
||||||
|
const preliminaryEntries = finalParticipants
|
||||||
|
.filter((entry) => entry.preliminaryGroupId === groupId)
|
||||||
|
.sort((lhs, rhs) => (lhs.preliminaryRank ?? 99) - (rhs.preliminaryRank ?? 99));
|
||||||
|
expect(preliminaryEntries).toHaveLength(8);
|
||||||
|
expect(preliminaryEntries.map((entry) => entry.preliminaryRank)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||||
|
expect(
|
||||||
|
preliminaryEntries.every(
|
||||||
|
(entry) =>
|
||||||
|
entry.preliminaryGroupNo !== undefined &&
|
||||||
|
entry.preliminaryWin !== undefined &&
|
||||||
|
entry.preliminaryDraw !== undefined &&
|
||||||
|
entry.preliminaryLose !== undefined &&
|
||||||
|
entry.preliminaryGl !== undefined
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -265,6 +265,71 @@ const commandTable = {
|
|||||||
],
|
],
|
||||||
inputOptions,
|
inputOptions,
|
||||||
};
|
};
|
||||||
|
const basicRecruitmentCrewTypes = [
|
||||||
|
{
|
||||||
|
id: 1100,
|
||||||
|
armType: 1,
|
||||||
|
name: '보병',
|
||||||
|
attack: 100,
|
||||||
|
defence: 150,
|
||||||
|
speed: 7,
|
||||||
|
avoid: 10,
|
||||||
|
baseCost: 9,
|
||||||
|
baseRice: 9,
|
||||||
|
info: ['표준적인 보병입니다.'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1200,
|
||||||
|
armType: 2,
|
||||||
|
name: '궁병',
|
||||||
|
attack: 100,
|
||||||
|
defence: 100,
|
||||||
|
speed: 7,
|
||||||
|
avoid: 20,
|
||||||
|
baseCost: 10,
|
||||||
|
baseRice: 10,
|
||||||
|
info: ['표준적인 궁병입니다.'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1300,
|
||||||
|
armType: 3,
|
||||||
|
name: '기병',
|
||||||
|
attack: 150,
|
||||||
|
defence: 100,
|
||||||
|
speed: 7,
|
||||||
|
avoid: 5,
|
||||||
|
baseCost: 11,
|
||||||
|
baseRice: 11,
|
||||||
|
info: ['표준적인 기병입니다.'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1400,
|
||||||
|
armType: 4,
|
||||||
|
name: '귀병',
|
||||||
|
attack: 80,
|
||||||
|
defence: 80,
|
||||||
|
speed: 7,
|
||||||
|
avoid: 5,
|
||||||
|
baseCost: 9,
|
||||||
|
baseRice: 9,
|
||||||
|
info: ['계략을 사용하는 병종입니다.'],
|
||||||
|
},
|
||||||
|
].map((crewType) => ({ ...crewType, available: true, special: false }));
|
||||||
|
const fourArmRecruitmentCommandTable = {
|
||||||
|
...commandTable,
|
||||||
|
inputOptions: {
|
||||||
|
...inputOptions,
|
||||||
|
crewTypes: basicRecruitmentCrewTypes.map((crewType) => ({ value: crewType.id, label: crewType.name })),
|
||||||
|
recruitment: {
|
||||||
|
...inputOptions.recruitment,
|
||||||
|
groups: basicRecruitmentCrewTypes.map((crewType) => ({
|
||||||
|
armType: crewType.armType,
|
||||||
|
armName: crewType.name,
|
||||||
|
values: [crewType],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
const buildSimpleCommand = (key: string, name: string) => ({
|
const buildSimpleCommand = (key: string, name: string) => ({
|
||||||
key,
|
key,
|
||||||
name,
|
name,
|
||||||
@@ -379,14 +444,25 @@ const generalContext = {
|
|||||||
name: '아국',
|
name: '아국',
|
||||||
color: '#008000',
|
color: '#008000',
|
||||||
level: 1,
|
level: 1,
|
||||||
levelName: '호족',
|
|
||||||
gold: 5000,
|
gold: 5000,
|
||||||
rice: 6000,
|
rice: 6000,
|
||||||
tech: 100,
|
tech: 100,
|
||||||
typeCode: 'che_중립',
|
|
||||||
typeName: '중립',
|
typeName: '중립',
|
||||||
capitalCityId: 1,
|
typePros: '',
|
||||||
capitalCityName: '업',
|
typeCons: '',
|
||||||
|
population: { cityCount: 1, current: 1000, max: 2000 },
|
||||||
|
crew: { generalCount: 2, current: 500, max: 7000 },
|
||||||
|
power: 1234,
|
||||||
|
bill: 100,
|
||||||
|
taxRate: 20,
|
||||||
|
strategicCommandLimit: 0,
|
||||||
|
diplomaticLimit: 0,
|
||||||
|
prohibitScout: false,
|
||||||
|
prohibitWar: false,
|
||||||
|
techLevel: 1,
|
||||||
|
techLimited: false,
|
||||||
|
topChiefs: {},
|
||||||
|
impossibleStrategicCommands: [],
|
||||||
},
|
},
|
||||||
settings: {},
|
settings: {},
|
||||||
penalties: {},
|
penalties: {},
|
||||||
@@ -1210,6 +1286,79 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
|
|||||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
|
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps arbitrary direct recruitment and mercenary amounts for all four arms after turn refresh', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const requests = await install(page, false, fourArmRecruitmentCommandTable);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
{ turn: 1, command: '징병', crewTypeId: 1100, name: '보병', inputAmount: 13, savedAmount: 1300 },
|
||||||
|
{ turn: 2, command: '징병', crewTypeId: 1200, name: '궁병', inputAmount: 27, savedAmount: 2700 },
|
||||||
|
{ turn: 3, command: '징병', crewTypeId: 1300, name: '기병', inputAmount: 41, savedAmount: 4100 },
|
||||||
|
{ turn: 4, command: '징병', crewTypeId: 1400, name: '귀병', inputAmount: 59, savedAmount: 5900 },
|
||||||
|
{ turn: 5, command: '모병', crewTypeId: 1100, name: '보병', inputAmount: 17, savedAmount: 1700 },
|
||||||
|
{ turn: 6, command: '모병', crewTypeId: 1200, name: '궁병', inputAmount: 31, savedAmount: 3100 },
|
||||||
|
{ turn: 7, command: '모병', crewTypeId: 1300, name: '기병', inputAmount: 43, savedAmount: 4300 },
|
||||||
|
{ turn: 8, command: '모병', crewTypeId: 1400, name: '귀병', inputAmount: 61, savedAmount: 6100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
await page.getByRole('button', { name: `${entry.turn}턴 명령 입력`, exact: true }).click();
|
||||||
|
const picker = page.getByTestId('command-picker');
|
||||||
|
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
||||||
|
await picker.getByRole('button', { name: entry.command, exact: true }).click();
|
||||||
|
|
||||||
|
const row = picker.getByRole('button', { name: `${entry.name} 선택 가능`, exact: true });
|
||||||
|
const amountInput = row.locator('input[type=number]');
|
||||||
|
await amountInput.fill(String(entry.inputAmount));
|
||||||
|
await expect(amountInput).toHaveValue(String(entry.inputAmount));
|
||||||
|
if (entry.turn === 1) {
|
||||||
|
const inputGeometry = await amountInput.evaluate((element) => {
|
||||||
|
if (!(element instanceof HTMLInputElement)) throw new Error('Expected recruitment amount input');
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
textAlign: getComputedStyle(element).textAlign,
|
||||||
|
value: element.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(inputGeometry).toMatchObject({ height: 28, textAlign: 'right', value: '13' });
|
||||||
|
expect(inputGeometry.width).toBeGreaterThan(0);
|
||||||
|
await picker.screenshot({ path: testInfo.outputPath('recruitment-direct-amount-desktop.png') });
|
||||||
|
}
|
||||||
|
await row.getByRole('button', { name: entry.command, exact: true }).click();
|
||||||
|
|
||||||
|
await expect(picker).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)).toHaveText(
|
||||||
|
`【${entry.name}】 ${entry.savedAmount}명 ${entry.command}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshResponse = page.waitForResponse((apiResponse) =>
|
||||||
|
decodeURIComponent(apiResponse.url()).includes('turns.reserved.getGeneral')
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: '갱 신', exact: true }).click();
|
||||||
|
await refreshResponse;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)).toHaveText(
|
||||||
|
`【${entry.name}】 ${entry.savedAmount}명 ${entry.command}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page
|
||||||
|
.locator('[data-command-scope="general"]')
|
||||||
|
.screenshot({ path: testInfo.outputPath('recruitment-arbitrary-amounts-after-refresh.png') });
|
||||||
|
|
||||||
|
const serializedRequests = JSON.stringify(requests);
|
||||||
|
for (const entry of entries) {
|
||||||
|
expect(serializedRequests).toContain(`"crewType":${entry.crewTypeId}`);
|
||||||
|
expect(serializedRequests).toContain(`"amount":${entry.savedAmount}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('uses the map to choose a nation target in the chief command window', async ({ page }) => {
|
test('uses the map to choose a nation target in the chief command window', async ({ page }) => {
|
||||||
await install(page);
|
await install(page);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
|||||||
@@ -995,6 +995,123 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
|||||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('split buttons keep square inner corners and a single divider in every interaction state', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 5,
|
||||||
|
permission: 2,
|
||||||
|
nationLevel: 3,
|
||||||
|
stage: 1,
|
||||||
|
npcMode: 1,
|
||||||
|
scenarioTitle: '분할 버튼 이음새 검증 시나리오',
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
nationColor: '#663399',
|
||||||
|
};
|
||||||
|
await installFixture(page, state);
|
||||||
|
if (artifactRoot) await mkdir(resolve(artifactRoot), { recursive: true });
|
||||||
|
|
||||||
|
const measure = (main: Locator, toggle: Locator) =>
|
||||||
|
Promise.all([
|
||||||
|
main.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: rect.toJSON(),
|
||||||
|
borderRightWidth: style.borderRightWidth,
|
||||||
|
borderTopLeftRadius: style.borderTopLeftRadius,
|
||||||
|
borderTopRightRadius: style.borderTopRightRadius,
|
||||||
|
borderBottomRightRadius: style.borderBottomRightRadius,
|
||||||
|
borderBottomLeftRadius: style.borderBottomLeftRadius,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
toggle.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: rect.toJSON(),
|
||||||
|
borderLeftWidth: style.borderLeftWidth,
|
||||||
|
borderTopLeftRadius: style.borderTopLeftRadius,
|
||||||
|
borderTopRightRadius: style.borderTopRightRadius,
|
||||||
|
borderBottomRightRadius: style.borderBottomRightRadius,
|
||||||
|
borderBottomLeftRadius: style.borderBottomLeftRadius,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const expectAttached = async (main: Locator, toggle: Locator) => {
|
||||||
|
const [mainStyle, toggleStyle] = await measure(main, toggle);
|
||||||
|
expect(mainStyle).toMatchObject({
|
||||||
|
borderRightWidth: '1px',
|
||||||
|
borderTopLeftRadius: '5.25px',
|
||||||
|
borderTopRightRadius: '0px',
|
||||||
|
borderBottomRightRadius: '0px',
|
||||||
|
borderBottomLeftRadius: '5.25px',
|
||||||
|
});
|
||||||
|
expect(toggleStyle).toMatchObject({
|
||||||
|
borderLeftWidth: '0px',
|
||||||
|
borderTopLeftRadius: '0px',
|
||||||
|
borderTopRightRadius: '5.25px',
|
||||||
|
borderBottomRightRadius: '5.25px',
|
||||||
|
borderBottomLeftRadius: '0px',
|
||||||
|
});
|
||||||
|
expect(toggleStyle.rect.left).toBeCloseTo(mainStyle.rect.right, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const width of [1200, 500]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await waitForMain(page);
|
||||||
|
const globalSplit = page.locator('.main-global-menu:visible .main-menu-split').first();
|
||||||
|
const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first();
|
||||||
|
const pairs: Array<[string, Locator, Locator]> = [
|
||||||
|
[
|
||||||
|
'global',
|
||||||
|
globalSplit.locator('[data-navigation-id="board-community"]'),
|
||||||
|
globalSplit.locator('[data-menu-id="boards"]'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'nation',
|
||||||
|
nationSplit.locator('[data-navigation-id="auction-resource"]'),
|
||||||
|
nationSplit.locator('[data-menu-id="auction"]'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [label, main, toggle] of pairs) {
|
||||||
|
await expect(main).toBeVisible();
|
||||||
|
await expect(toggle).toBeVisible();
|
||||||
|
await page.mouse.move(width - 1, 899);
|
||||||
|
await expectAttached(main, toggle);
|
||||||
|
|
||||||
|
await toggle.focus();
|
||||||
|
await expect(toggle).toBeFocused();
|
||||||
|
await expectAttached(main, toggle);
|
||||||
|
|
||||||
|
await toggle.hover();
|
||||||
|
await expectAttached(main, toggle);
|
||||||
|
|
||||||
|
const box = await toggle.boundingBox();
|
||||||
|
if (!box) throw new Error(`${label} split toggle is not measurable`);
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expectAttached(main, toggle);
|
||||||
|
await page.mouse.move(width - 1, 899);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await toggle.click();
|
||||||
|
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await expectAttached(main, toggle);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
await toggle.locator('..').screenshot({
|
||||||
|
path: artifactRoot
|
||||||
|
? resolve(artifactRoot, `${basePath.slice(1)}-${width}-${label}-split-button.png`)
|
||||||
|
: testInfo.outputPath(`${width}-${label}-split-button.png`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('the repeated bottom global menu opens upward on the mobile document', async ({ page }, testInfo) => {
|
test('the repeated bottom global menu opens upward on the mobile document', async ({ page }, testInfo) => {
|
||||||
const state: NavigationFixture = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 5,
|
officerLevel: 5,
|
||||||
@@ -2107,16 +2224,24 @@ test('mobile bottom controls share Ref pressed geometry while their color bases
|
|||||||
await expect
|
await expect
|
||||||
.poll(() => page.locator(manualRefreshSelector).evaluate((element) => getComputedStyle(element).boxShadow))
|
.poll(() => page.locator(manualRefreshSelector).evaluate((element) => getComputedStyle(element).boxShadow))
|
||||||
.not.toBe('none');
|
.not.toBe('none');
|
||||||
|
const callsBeforeManualRefresh = state.generalMeCalls;
|
||||||
await page.locator(manualRefreshSelector).click();
|
await page.locator(manualRefreshSelector).click();
|
||||||
await expect(page.locator(manualRefreshSelector)).toBeDisabled();
|
await expect(page.locator(manualRefreshSelector)).toBeEnabled();
|
||||||
|
await expect(page.locator(manualRefreshSelector)).toHaveAttribute('aria-busy', 'true');
|
||||||
|
await page.locator(manualRefreshSelector).click();
|
||||||
|
await expect(page.getByTestId('game-toast')).toContainText('이미 정보를 갱신하고 있습니다.');
|
||||||
|
await page.mouse.move(1, 1);
|
||||||
expect(await buttonStyle(manualRefreshSelector)).toMatchObject({
|
expect(await buttonStyle(manualRefreshSelector)).toMatchObject({
|
||||||
backgroundColor: 'rgb(33, 37, 41)',
|
backgroundColor: 'rgb(33, 37, 41)',
|
||||||
borderBottomWidth: '4px',
|
borderBottomWidth: '4px',
|
||||||
marginTop: '0px',
|
marginTop: '0px',
|
||||||
height: 45,
|
height: 45,
|
||||||
});
|
});
|
||||||
await expect(page.locator(manualRefreshSelector)).toHaveCSS('opacity', '0.55');
|
await expect(page.locator(manualRefreshSelector)).toHaveCSS('opacity', '1');
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('mobile-refresh-busy-toast.png'), fullPage: true });
|
||||||
|
await expect(page.locator(manualRefreshSelector)).toHaveAttribute('aria-busy', 'false');
|
||||||
await expect(page.locator(manualRefreshSelector)).toBeEnabled();
|
await expect(page.locator(manualRefreshSelector)).toBeEnabled();
|
||||||
|
expect(state.generalMeCalls).toBe(callsBeforeManualRefresh + 1);
|
||||||
|
|
||||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-ref-buttons`);
|
await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-ref-buttons`);
|
||||||
});
|
});
|
||||||
@@ -2685,6 +2810,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
|
|
||||||
const operationsBeforeChangedBurst = state.operations.length;
|
const operationsBeforeChangedBurst = state.operations.length;
|
||||||
state.generalName = '부드럽게갱신된장수';
|
state.generalName = '부드럽게갱신된장수';
|
||||||
|
state.refreshDelayMs = 1_000;
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void })
|
const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void })
|
||||||
.__emitMainRealtime;
|
.__emitMainRealtime;
|
||||||
@@ -2706,7 +2832,20 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const manualRefresh = page.getByRole('button', { name: '갱 신' });
|
||||||
|
await expect(manualRefresh).toHaveAttribute('aria-busy', 'true');
|
||||||
|
await expect(manualRefresh).toBeEnabled();
|
||||||
|
await manualRefresh.click();
|
||||||
|
await expect(page.getByTestId('game-toast')).toContainText('이미 정보를 갱신하고 있습니다.');
|
||||||
|
if (autoRefreshArtifactRoot) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(autoRefreshArtifactRoot, 'auto-refresh-busy-feedback.png'),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await expect.poll(() => state.generalMeCalls, { timeout: 3_000 }).toBe(callsBeforeRefresh + 1);
|
await expect.poll(() => state.generalMeCalls, { timeout: 3_000 }).toBe(callsBeforeRefresh + 1);
|
||||||
|
state.refreshDelayMs = 300;
|
||||||
await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0);
|
await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0);
|
||||||
await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0);
|
await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0);
|
||||||
await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'false');
|
await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'false');
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const names = [
|
|||||||
'허저',
|
'허저',
|
||||||
'주태',
|
'주태',
|
||||||
longGeneralName,
|
longGeneralName,
|
||||||
|
...Array.from({ length: 48 }, (_, index) => `예선장수${index + 17}`),
|
||||||
];
|
];
|
||||||
const participants = names.map((name, index) => ({
|
const participants = names.map((name, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
@@ -45,13 +46,20 @@ const participants = names.map((name, index) => ({
|
|||||||
level: 10,
|
level: 10,
|
||||||
picture: 'default.jpg',
|
picture: 'default.jpg',
|
||||||
imageServer: 0,
|
imageServer: 0,
|
||||||
groupId: 10 + (index % 8),
|
groupId: Math.floor(index / 8) < 4 ? 10 + (index % 8) : index % 8,
|
||||||
groupNo: Math.floor(index / 8),
|
groupNo: Math.floor(index / 8),
|
||||||
win: 3 - (index % 2),
|
win: Math.floor(index / 8) < 4 ? 3 - (index % 2) : 7 - Math.floor(index / 8),
|
||||||
draw: index % 2,
|
draw: index % 2,
|
||||||
lose: 0,
|
lose: Math.floor(index / 8) < 4 ? 0 : Math.floor(index / 8),
|
||||||
gl: 12 - index,
|
gl: 64 - index,
|
||||||
finalRank: Math.floor(index / 8) + 1,
|
finalRank: Math.floor(index / 8) + 1,
|
||||||
|
preliminaryGroupId: index % 8,
|
||||||
|
preliminaryGroupNo: Math.floor(index / 8),
|
||||||
|
preliminaryRank: Math.floor(index / 8) + 1,
|
||||||
|
preliminaryWin: 7 - Math.floor(index / 8),
|
||||||
|
preliminaryDraw: index % 2,
|
||||||
|
preliminaryLose: Math.floor(index / 8),
|
||||||
|
preliminaryGl: 64 - index,
|
||||||
}));
|
}));
|
||||||
const matches = [
|
const matches = [
|
||||||
...Array.from({ length: 8 }, (_, index) => ({
|
...Array.from({ length: 8 }, (_, index) => ({
|
||||||
@@ -181,11 +189,11 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean }
|
|||||||
if (operation === 'tournament.getBettingSummary') {
|
if (operation === 'tournament.getBettingSummary') {
|
||||||
return response({
|
return response({
|
||||||
totals: Object.fromEntries(
|
totals: Object.fromEntries(
|
||||||
participants.map((participant, index) => [participant.id, 100 + index * 10])
|
participants.slice(0, 16).map((participant, index) => [participant.id, 100 + index * 10])
|
||||||
),
|
),
|
||||||
myTotals: {},
|
myTotals: { 1: 120, 2: 40 },
|
||||||
totalAmount: 2800,
|
totalAmount: 2800,
|
||||||
myAmount: 0,
|
myAmount: 160,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (operation === 'tournament.getRankings') {
|
if (operation === 'tournament.getRankings') {
|
||||||
@@ -297,6 +305,14 @@ test('desktop bracket connects every real general slot to the next round', async
|
|||||||
expect(oddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
|
expect(oddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
|
||||||
expect(oddsContainment.oddsTop).toBeGreaterThanOrEqual(oddsContainment.cardTop);
|
expect(oddsContainment.oddsTop).toBeGreaterThanOrEqual(oddsContainment.cardTop);
|
||||||
expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom);
|
expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom);
|
||||||
|
await expect(firstSlot.locator('.bracket-my-bet')).toHaveText('내 투자 금120');
|
||||||
|
|
||||||
|
const preliminaryTables = page.locator('.preliminary-grid table');
|
||||||
|
await expect(preliminaryTables).toHaveCount(8);
|
||||||
|
for (let groupIndex = 0; groupIndex < 8; groupIndex += 1) {
|
||||||
|
await expect(preliminaryTables.nth(groupIndex).locator('tbody tr')).toHaveCount(8);
|
||||||
|
await expect(preliminaryTables.nth(groupIndex).locator('.general-identity')).toHaveCount(8);
|
||||||
|
}
|
||||||
|
|
||||||
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
|
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
|
||||||
});
|
});
|
||||||
@@ -413,6 +429,7 @@ test('mobile bracket exposes every round through tabs with standard horizontal i
|
|||||||
});
|
});
|
||||||
expect(mobileOddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
|
expect(mobileOddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
|
||||||
expect(mobileOddsContainment.oddsBottom).toBeLessThanOrEqual(mobileOddsContainment.cardBottom);
|
expect(mobileOddsContainment.oddsBottom).toBeLessThanOrEqual(mobileOddsContainment.cardBottom);
|
||||||
|
await expect(firstMobileSlot.locator('.bracket-my-bet')).toHaveText('내 투자 금120');
|
||||||
await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible();
|
await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible();
|
||||||
await page.getByRole('tab', { name: '二조' }).first().click();
|
await page.getByRole('tab', { name: '二조' }).first().click();
|
||||||
await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true');
|
await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true');
|
||||||
@@ -420,12 +437,39 @@ test('mobile bracket exposes every round through tabs with standard horizontal i
|
|||||||
await persistScreenshot(page, 'tournament-mobile', testInfo.outputPath('tournament-bracket-mobile.webp'));
|
await persistScreenshot(page, 'tournament-mobile', testInfo.outputPath('tournament-bracket-mobile.webp'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tournament and betting pages expose same-row navigation tabs beside close', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await openTournament(page);
|
||||||
|
|
||||||
|
const navigation = page.getByRole('tablist', { name: '토너먼트와 베팅장 이동' });
|
||||||
|
const tournamentTab = navigation.getByRole('tab', { name: '토너먼트' });
|
||||||
|
const bettingTab = navigation.getByRole('tab', { name: '베팅장' });
|
||||||
|
const close = page.getByRole('button', { name: '창 닫기' }).first();
|
||||||
|
await expect(tournamentTab).toHaveAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
const headerCenters = await Promise.all(
|
||||||
|
[tournamentTab, bettingTab, close].map(async (control) => {
|
||||||
|
const box = await control.boundingBox();
|
||||||
|
return box ? box.y + box.height / 2 : -1;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(Math.max(...headerCenters) - Math.min(...headerCenters)).toBeLessThan(1);
|
||||||
|
|
||||||
|
await bettingTab.click();
|
||||||
|
await expect(page).toHaveURL(/\/betting$/);
|
||||||
|
await expect(page.getByRole('tab', { name: '베팅장' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await page.getByRole('tab', { name: '토너먼트' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/tournament$/);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||||
|
});
|
||||||
|
|
||||||
test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => {
|
test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await installFixture(page);
|
await installFixture(page);
|
||||||
await page.goto('betting');
|
await page.goto('betting');
|
||||||
|
|
||||||
await expect(page.locator('.candidate-card')).toHaveCount(16);
|
await expect(page.locator('.candidate-card')).toHaveCount(16);
|
||||||
|
await expect(page.locator('.betting-bracket .bracket-my-bet').first()).toHaveText('내 투자 금120');
|
||||||
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
|
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
|
||||||
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
|
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
|
||||||
await page.getByRole('tab', { name: '통솔전' }).click();
|
await page.getByRole('tab', { name: '통솔전' }).click();
|
||||||
|
|||||||
@@ -153,3 +153,18 @@
|
|||||||
border-color: var(--legacy-button-border);
|
border-color: var(--legacy-button-border);
|
||||||
background: var(--legacy-button-bg);
|
background: var(--legacy-button-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Split controls share one outer silhouette. Keep the main control's right
|
||||||
|
* border as the subtle divider while removing every inner corner and the
|
||||||
|
* toggle's overlapping left border. These rules intentionally follow the
|
||||||
|
* Lumen family so its border shorthand cannot restore the inner rounding.
|
||||||
|
*/
|
||||||
|
.legacy-split-button > .main-menu-link {
|
||||||
|
border-radius: 5.25px 0 0 5.25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-split-button > .legacy-split-button__toggle {
|
||||||
|
border-left-width: 0;
|
||||||
|
border-radius: 0 5.25px 5.25px 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const updateAmount = (event: Event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submit = async (crewType?: RecruitmentCrewType) => {
|
const submit = async (crewType?: RecruitmentCrewType) => {
|
||||||
if (crewType) selectCrewType(crewType);
|
if (crewType && crewType.id !== selectedCrewTypeId.value) selectCrewType(crewType);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if (valid.value) emit('submit');
|
if (valid.value) emit('submit');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
|
|||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="main-menu-split">
|
<div v-else class="main-menu-split legacy-split-button">
|
||||||
<MainNavigationLink
|
<MainNavigationLink
|
||||||
:link="entry.main"
|
:link="entry.main"
|
||||||
:enabled="isNavigationConfigured(entry.main)"
|
:enabled="isNavigationConfigured(entry.main)"
|
||||||
@@ -67,7 +67,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
|
|||||||
lumen-variant="navigation"
|
lumen-variant="navigation"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="main-menu-button main-menu-split__toggle legacy-button legacy-button--navigation"
|
class="main-menu-button main-menu-split__toggle legacy-split-button__toggle legacy-button legacy-button--navigation"
|
||||||
type="button"
|
type="button"
|
||||||
:data-menu-id="entry.id"
|
:data-menu-id="entry.id"
|
||||||
:aria-label="`${entry.main.label} 하위 메뉴`"
|
:aria-label="`${entry.main.label} 하위 메뉴`"
|
||||||
@@ -131,12 +131,6 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
|
|||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-left-width: 0;
|
|
||||||
border-radius: 0 5.25px 5.25px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-menu-split > :deep(.main-menu-link) {
|
|
||||||
border-radius: 5.25px 0 0 5.25px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-caret {
|
.menu-caret {
|
||||||
|
|||||||
@@ -226,7 +226,6 @@ const onQuick = (item: QuickNavigationItem) => {
|
|||||||
data-bottom-menu="manual-refresh"
|
data-bottom-menu="manual-refresh"
|
||||||
aria-label="직접 갱신"
|
aria-label="직접 갱신"
|
||||||
title="직접 갱신"
|
title="직접 갱신"
|
||||||
:disabled="refreshing"
|
|
||||||
:aria-busy="refreshing"
|
:aria-busy="refreshing"
|
||||||
@click="emit('refresh')"
|
@click="emit('refresh')"
|
||||||
>
|
>
|
||||||
@@ -306,12 +305,6 @@ const onQuick = (item: QuickNavigationItem) => {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.manual-refresh-trigger:disabled {
|
|
||||||
cursor: wait;
|
|
||||||
filter: grayscale(0.6);
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true']):hover,
|
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true']):hover,
|
||||||
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true'])[aria-expanded='true'] {
|
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true'])[aria-expanded='true'] {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
|
|||||||
:active="isActive(entry)"
|
:active="isActive(entry)"
|
||||||
lumen-variant="lumen"
|
lumen-variant="lumen"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="entry.kind === 'split'" class="nation-menu-split">
|
<div v-else-if="entry.kind === 'split'" class="nation-menu-split legacy-split-button">
|
||||||
<MainNavigationLink
|
<MainNavigationLink
|
||||||
:link="entry.main"
|
:link="entry.main"
|
||||||
:enabled="isNationNavigationEnabled(entry.main, access)"
|
:enabled="isNationNavigationEnabled(entry.main, access)"
|
||||||
@@ -43,7 +43,7 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
|
|||||||
lumen-variant="lumen"
|
lumen-variant="lumen"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="main-menu-button nation-menu-split__toggle legacy-button legacy-button--lumen"
|
class="main-menu-button nation-menu-split__toggle legacy-split-button__toggle legacy-button legacy-button--lumen"
|
||||||
type="button"
|
type="button"
|
||||||
:data-menu-id="entry.id"
|
:data-menu-id="entry.id"
|
||||||
:aria-label="`${entry.main.label} 하위 메뉴`"
|
:aria-label="`${entry.main.label} 하위 메뉴`"
|
||||||
@@ -108,12 +108,6 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
|
|||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-left-width: 0;
|
|
||||||
border-radius: 0 5.25px 5.25px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nation-menu-split > :deep(.main-menu-link) {
|
|
||||||
border-radius: 5.25px 0 0 5.25px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-caret {
|
.menu-caret {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const props = defineProps<{
|
|||||||
matches: TournamentBracketMatch[];
|
matches: TournamentBracketMatch[];
|
||||||
winnerId?: number;
|
winnerId?: number;
|
||||||
betTotals?: Record<number, number>;
|
betTotals?: Record<number, number>;
|
||||||
|
myBetTotals?: Record<number, number>;
|
||||||
totalBet: number;
|
totalBet: number;
|
||||||
showLegend?: boolean;
|
showLegend?: boolean;
|
||||||
}>();
|
}>();
|
||||||
@@ -66,6 +67,7 @@ const odds = (id: number | null) => {
|
|||||||
if (!amount) return '∞';
|
if (!amount) return '∞';
|
||||||
return (props.totalBet / amount).toFixed(2);
|
return (props.totalBet / amount).toFixed(2);
|
||||||
};
|
};
|
||||||
|
const myBet = (id: number | null) => (id === null ? 0 : (props.myBetTotals?.[id] ?? 0));
|
||||||
const mobilePairs = computed(() => {
|
const mobilePairs = computed(() => {
|
||||||
const column = roundColumns.value[activeMobileRound.value] ?? [];
|
const column = roundColumns.value[activeMobileRound.value] ?? [];
|
||||||
if (activeMobileRound.value === roundColumns.value.length - 1) return column.map((slot) => [slot]);
|
if (activeMobileRound.value === roundColumns.value.length - 1) return column.map((slot) => [slot]);
|
||||||
@@ -116,7 +118,10 @@ const mobilePairs = computed(() => {
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
||||||
<small v-if="columnIndex === 0" class="bracket-odds">배당 {{ odds(slot.id) }}</small>
|
<div v-if="columnIndex === 0" class="bracket-bet-summary">
|
||||||
|
<small class="bracket-odds">배당 {{ odds(slot.id) }}</small>
|
||||||
|
<small class="bracket-my-bet">내 투자 금{{ myBet(slot.id) }}</small>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +151,10 @@ const mobilePairs = computed(() => {
|
|||||||
:data-general-id="slot.id ?? undefined"
|
:data-general-id="slot.id ?? undefined"
|
||||||
>
|
>
|
||||||
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
||||||
<small v-if="activeMobileRound === 0" class="bracket-odds">배당 {{ odds(slot.id) }}</small>
|
<div v-if="activeMobileRound === 0" class="bracket-bet-summary">
|
||||||
|
<small class="bracket-odds">배당 {{ odds(slot.id) }}</small>
|
||||||
|
<small class="bracket-my-bet">내 투자 금{{ myBet(slot.id) }}</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<strong v-if="pair.length === 2" class="versus" aria-hidden="true">VS</strong>
|
<strong v-if="pair.length === 2" class="versus" aria-hidden="true">VS</strong>
|
||||||
</article>
|
</article>
|
||||||
@@ -220,12 +228,26 @@ const mobilePairs = computed(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
.bracket-odds {
|
.bracket-bet-summary {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bracket-odds,
|
||||||
|
.bracket-my-bet {
|
||||||
display: block;
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
color: skyblue;
|
color: skyblue;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 12px;
|
line-height: 12px;
|
||||||
text-align: right;
|
}
|
||||||
|
.bracket-my-bet {
|
||||||
|
overflow: hidden;
|
||||||
|
color: orange;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.mobile-bracket {
|
.mobile-bracket {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
activePage: 'tournament' | 'betting';
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="tournament-page-header">
|
||||||
|
<strong class="tournament-page-title">{{ title }}</strong>
|
||||||
|
<div class="tournament-page-actions">
|
||||||
|
<nav class="tournament-page-tabs" role="tablist" aria-label="토너먼트와 베팅장 이동">
|
||||||
|
<RouterLink v-slot="{ navigate }" custom to="/tournament">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activePage === 'tournament'"
|
||||||
|
:class="{ active: activePage === 'tournament' }"
|
||||||
|
@click="navigate"
|
||||||
|
>
|
||||||
|
토너먼트
|
||||||
|
</button>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink v-slot="{ navigate }" custom to="/betting">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activePage === 'betting'"
|
||||||
|
:class="{ active: activePage === 'betting' }"
|
||||||
|
@click="navigate"
|
||||||
|
>
|
||||||
|
베팅장
|
||||||
|
</button>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
<RouterLink v-slot="{ navigate }" custom to="/">
|
||||||
|
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tournament-page-header {
|
||||||
|
display: flex;
|
||||||
|
min-height: 48px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.tournament-page-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tournament-page-actions,
|
||||||
|
.tournament-page-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
height: 44px;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 5.25px;
|
||||||
|
background: #444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tournament-page-tabs button {
|
||||||
|
min-width: 72px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.tournament-page-tabs button.active {
|
||||||
|
border-color: #f39c12;
|
||||||
|
background: #8a5b13;
|
||||||
|
}
|
||||||
|
.close-button {
|
||||||
|
width: 88px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-color: #375a7f;
|
||||||
|
background: #375a7f;
|
||||||
|
}
|
||||||
|
button:hover,
|
||||||
|
button:focus {
|
||||||
|
filter: brightness(1.25);
|
||||||
|
}
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid #f39c12;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.tournament-page-header {
|
||||||
|
gap: 4px;
|
||||||
|
padding-inline: 2px;
|
||||||
|
}
|
||||||
|
.tournament-page-title {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tournament-page-actions,
|
||||||
|
.tournament-page-tabs {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.tournament-page-tabs button {
|
||||||
|
min-width: 68px;
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { formatServerDateTime } from '@sammo-ts/common';
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||||
|
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
||||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ const candidates = computed(() =>
|
|||||||
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
||||||
const myAmount = computed(() => summary.value?.myAmount ?? 0);
|
const myAmount = computed(() => summary.value?.myAmount ?? 0);
|
||||||
const betTotals = computed(() => summary.value?.totals as Record<number, number> | undefined);
|
const betTotals = computed(() => summary.value?.totals as Record<number, number> | undefined);
|
||||||
|
const myBetTotals = computed(() => summary.value?.myTotals as Record<number, number> | undefined);
|
||||||
const ratio = (id: number) => {
|
const ratio = (id: number) => {
|
||||||
const totals = summary.value?.totals as Record<number, number> | undefined;
|
const totals = summary.value?.totals as Record<number, number> | undefined;
|
||||||
const amount = totals?.[id] ?? 0;
|
const amount = totals?.[id] ?? 0;
|
||||||
@@ -110,12 +112,7 @@ const placeBet = async (targetId: number) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main id="tournament-betting-container" class="betting-page">
|
<main id="tournament-betting-container" class="betting-page">
|
||||||
<section class="title bg0">
|
<TournamentPageHeader class="bg0" active-page="betting" title="베 팅 장" />
|
||||||
베 팅 장<br />
|
|
||||||
<RouterLink v-slot="{ navigate }" custom to="/">
|
|
||||||
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
|
||||||
</RouterLink>
|
|
||||||
</section>
|
|
||||||
<section class="toolbar bg0">
|
<section class="toolbar bg0">
|
||||||
<button type="button" @click="load">갱신</button>
|
<button type="button" @click="load">갱신</button>
|
||||||
<span v-if="loading">불러오는 중...</span>
|
<span v-if="loading">불러오는 중...</span>
|
||||||
@@ -138,6 +135,7 @@ const placeBet = async (targetId: number) => {
|
|||||||
:matches="snapshot?.matches ?? []"
|
:matches="snapshot?.matches ?? []"
|
||||||
:winner-id="snapshot?.state?.winnerId"
|
:winner-id="snapshot?.state?.winnerId"
|
||||||
:bet-totals="betTotals"
|
:bet-totals="betTotals"
|
||||||
|
:my-bet-totals="myBetTotals"
|
||||||
:total-bet="totalAmount"
|
:total-bet="totalAmount"
|
||||||
:show-legend="false"
|
:show-legend="false"
|
||||||
/>
|
/>
|
||||||
@@ -303,25 +301,6 @@ const placeBet = async (targetId: number) => {
|
|||||||
.bg2 {
|
.bg2 {
|
||||||
background: #142b42 var(--sammo-texture-blue);
|
background: #142b42 var(--sammo-texture-blue);
|
||||||
}
|
}
|
||||||
.title {
|
|
||||||
min-height: 68px;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 19.1875px;
|
|
||||||
}
|
|
||||||
.close-button {
|
|
||||||
display: block;
|
|
||||||
width: 88px;
|
|
||||||
height: 44px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border: 1px solid #375a7f;
|
|
||||||
border-radius: 5.25px;
|
|
||||||
background: #375a7f;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 18px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
min-height: 46px;
|
min-height: 46px;
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
@@ -417,10 +396,6 @@ button:hover,
|
|||||||
button:focus {
|
button:focus {
|
||||||
filter: brightness(1.25);
|
filter: brightness(1.25);
|
||||||
}
|
}
|
||||||
.close-button:hover,
|
|
||||||
.close-button:focus {
|
|
||||||
filter: brightness(1.2);
|
|
||||||
}
|
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
select:focus-visible {
|
select:focus-visible {
|
||||||
outline: 2px solid #f39c12;
|
outline: 2px solid #f39c12;
|
||||||
@@ -507,10 +482,6 @@ select:disabled {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.title {
|
|
||||||
height: auto;
|
|
||||||
min-height: 55px;
|
|
||||||
}
|
|
||||||
.state {
|
.state {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ import type { QuickNavigationItem } from '../components/main/mainNavigation';
|
|||||||
import { formatLog } from '../utils/formatLog';
|
import { formatLog } from '../utils/formatLog';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
import { useMainDashboardStore } from '../stores/mainDashboard';
|
import { useMainDashboardStore } from '../stores/mainDashboard';
|
||||||
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import type { CommandPatternEntry } from '../components/command/types';
|
import type { CommandPatternEntry } from '../components/command/types';
|
||||||
|
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const dashboard = useMainDashboardStore();
|
const dashboard = useMainDashboardStore();
|
||||||
|
const { info: showInfoToast } = useGameFeedback();
|
||||||
const isMobile = useMediaQuery('(max-width: 939.98px)');
|
const isMobile = useMediaQuery('(max-width: 939.98px)');
|
||||||
|
|
||||||
const npcMode = ref(0);
|
const npcMode = ref(0);
|
||||||
@@ -115,6 +117,14 @@ const loadMainData = async () => {
|
|||||||
npcMode.value = worldState?.config.npcMode ?? 0;
|
npcMode.value = worldState?.config.npcMode ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestManualRefresh = () => {
|
||||||
|
if (refreshing.value) {
|
||||||
|
showInfoToast('이미 정보를 갱신하고 있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadMainData();
|
||||||
|
};
|
||||||
|
|
||||||
const moveLobby = () => {
|
const moveLobby = () => {
|
||||||
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
|
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
|
||||||
};
|
};
|
||||||
@@ -154,9 +164,8 @@ watch(
|
|||||||
<button
|
<button
|
||||||
class="game-shell__action legacy-button legacy-button--navigation"
|
class="game-shell__action legacy-button legacy-button--navigation"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="refreshing"
|
|
||||||
:aria-busy="refreshing"
|
:aria-busy="refreshing"
|
||||||
@click="loadMainData"
|
@click="requestManualRefresh"
|
||||||
>
|
>
|
||||||
갱 신
|
갱 신
|
||||||
</button>
|
</button>
|
||||||
@@ -451,7 +460,7 @@ watch(
|
|||||||
:npc-mode="npcMode"
|
:npc-mode="npcMode"
|
||||||
:realtime-enabled="realtimeEnabled"
|
:realtime-enabled="realtimeEnabled"
|
||||||
:refreshing="refreshing"
|
:refreshing="refreshing"
|
||||||
@refresh="loadMainData"
|
@refresh="requestManualRefresh"
|
||||||
@toggle-realtime="dashboard.setRealtimeEnabled(!realtimeEnabled)"
|
@toggle-realtime="dashboard.setRealtimeEnabled(!realtimeEnabled)"
|
||||||
@lobby="moveLobby"
|
@lobby="moveLobby"
|
||||||
@quick="moveQuick"
|
@quick="moveQuick"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { formatServerDateTime } from '@sammo-ts/common';
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||||
|
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
||||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { resolveTournamentStageName } from '../utils/tournamentStatus';
|
import { resolveTournamentStageName } from '../utils/tournamentStatus';
|
||||||
@@ -58,6 +59,7 @@ const openingTime = computed(() =>
|
|||||||
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
||||||
);
|
);
|
||||||
const betTotals = computed(() => betting.value?.totals as Record<number, number> | undefined);
|
const betTotals = computed(() => betting.value?.totals as Record<number, number> | undefined);
|
||||||
|
const myBetTotals = computed(() => betting.value?.myTotals as Record<number, number> | undefined);
|
||||||
const isParticipant = computed(() =>
|
const isParticipant = computed(() =>
|
||||||
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
|
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
|
||||||
);
|
);
|
||||||
@@ -71,8 +73,25 @@ const groups = computed(() =>
|
|||||||
const preliminaryGroups = computed(() =>
|
const preliminaryGroups = computed(() =>
|
||||||
Array.from({ length: 8 }, (_, index) =>
|
Array.from({ length: 8 }, (_, index) =>
|
||||||
(snapshot.value?.participants ?? [])
|
(snapshot.value?.participants ?? [])
|
||||||
.filter((participant) => participant.groupId === index)
|
.filter((participant) => {
|
||||||
.sort((a, b) => (a.seedRank ?? 99) - (b.seedRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
|
const groupId =
|
||||||
|
participant.preliminaryGroupId ??
|
||||||
|
(participant.groupId !== undefined && participant.groupId < 8 ? participant.groupId : undefined);
|
||||||
|
return groupId === index;
|
||||||
|
})
|
||||||
|
.map((participant) => ({
|
||||||
|
...participant,
|
||||||
|
groupNo: participant.preliminaryGroupNo ?? participant.groupNo,
|
||||||
|
win: participant.preliminaryWin ?? participant.win,
|
||||||
|
draw: participant.preliminaryDraw ?? participant.draw,
|
||||||
|
lose: participant.preliminaryLose ?? participant.lose,
|
||||||
|
gl: participant.preliminaryGl ?? participant.gl,
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.preliminaryRank ?? a.seedRank ?? 99) - (b.preliminaryRank ?? b.seedRank ?? 99) ||
|
||||||
|
(a.groupNo ?? 99) - (b.groupNo ?? 99)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const groupNames = ['一', '二', '三', '四', '五', '六', '七', '八'];
|
const groupNames = ['一', '二', '三', '四', '五', '六', '七', '八'];
|
||||||
@@ -141,12 +160,7 @@ const start = async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main id="tournament-container" class="legacy-page">
|
<main id="tournament-container" class="legacy-page">
|
||||||
<section class="legacy-title bg0">
|
<TournamentPageHeader class="bg0" active-page="tournament" title="삼모전 토너먼트" />
|
||||||
<div>삼모전 토너먼트</div>
|
|
||||||
<RouterLink v-slot="{ navigate }" custom to="/">
|
|
||||||
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
|
||||||
</RouterLink>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="toolbar bg0">
|
<section class="toolbar bg0">
|
||||||
<button type="button" @click="load">갱신</button>
|
<button type="button" @click="load">갱신</button>
|
||||||
@@ -177,6 +191,7 @@ const start = async () => {
|
|||||||
:matches="snapshot?.matches ?? []"
|
:matches="snapshot?.matches ?? []"
|
||||||
:winner-id="snapshot?.state?.winnerId"
|
:winner-id="snapshot?.state?.winnerId"
|
||||||
:bet-totals="betTotals"
|
:bet-totals="betTotals"
|
||||||
|
:my-bet-totals="myBetTotals"
|
||||||
:total-bet="totalBet"
|
:total-bet="totalBet"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -384,25 +399,6 @@ const start = async () => {
|
|||||||
.bg2 {
|
.bg2 {
|
||||||
background: #142b42 var(--sammo-texture-blue);
|
background: #142b42 var(--sammo-texture-blue);
|
||||||
}
|
}
|
||||||
.legacy-title {
|
|
||||||
min-height: 68px;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 19.1875px;
|
|
||||||
}
|
|
||||||
.close-button {
|
|
||||||
display: block;
|
|
||||||
width: 88px;
|
|
||||||
height: 44px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border: 1px solid #375a7f;
|
|
||||||
border-radius: 5.25px;
|
|
||||||
background: #375a7f;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 18px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
min-height: 46px;
|
min-height: 46px;
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
@@ -433,10 +429,6 @@ button:hover,
|
|||||||
button:focus {
|
button:focus {
|
||||||
filter: brightness(1.25);
|
filter: brightness(1.25);
|
||||||
}
|
}
|
||||||
.close-button:hover,
|
|
||||||
.close-button:focus {
|
|
||||||
filter: brightness(1.2);
|
|
||||||
}
|
|
||||||
button:focus-visible {
|
button:focus-visible {
|
||||||
outline: 2px solid #f39c12;
|
outline: 2px solid #f39c12;
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
@@ -532,10 +524,6 @@ td {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.legacy-title {
|
|
||||||
height: auto;
|
|
||||||
min-height: 55px;
|
|
||||||
}
|
|
||||||
.state-row {
|
.state-row {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,20 @@
|
|||||||
Gateway API와 game API는 기본적으로 `0.0.0.0`에 bind합니다. 실제 port와
|
Gateway API와 game API는 기본적으로 `0.0.0.0`에 bind합니다. 실제 port와
|
||||||
prefix는 환경 변수와 배포 profile이 결정합니다.
|
prefix는 환경 변수와 배포 profile이 결정합니다.
|
||||||
|
|
||||||
|
현재 PM2 조립에서 game profile 하나는 frontend, API, turn daemon, auction,
|
||||||
|
battle-sim, tournament worker의 여섯 process를 만듭니다. 각 정의에는
|
||||||
|
`instances`나 cluster `exec_mode`가 없으므로 모두 단일 fork입니다. frontend도
|
||||||
|
Caddy 정적 파일이 아니라 profile별 Vite preview Node process이고, API도 하나의
|
||||||
|
Fastify process입니다. worker 역할 분리는 API event loop의 작업을 줄이지만
|
||||||
|
frontend/API replica나 장애 대체 backend를 제공하지는 않습니다.
|
||||||
|
|
||||||
|
Profile은 PostgreSQL schema와 Redis namespace를 분리하지만 같은 database,
|
||||||
|
PostgreSQL instance, runtime cgroup을 공유합니다. 현재 `PrismaPg` adapter에는
|
||||||
|
role별 pool 상한을 명시하지 않아 각 DB 사용 process가 `pg` 기본 pool 상한을
|
||||||
|
독립적으로 가질 수 있습니다. 따라서 profile 수를 늘릴 때는 process RSS뿐 아니라
|
||||||
|
API, daemon, 세 worker와 Gateway 계열의 합산 connection budget을 PostgreSQL
|
||||||
|
`max_connections` 안에서 먼저 정해야 합니다.
|
||||||
|
|
||||||
## Gateway 실행
|
## Gateway 실행
|
||||||
|
|
||||||
`resolveGatewayApiConfigFromEnv()`가 PostgreSQL schema, Redis prefix, session
|
`resolveGatewayApiConfigFromEnv()`가 PostgreSQL schema, Redis prefix, session
|
||||||
@@ -168,6 +182,22 @@ Checkpoint의 단일 소유자는 `InMemoryTurnWorld`이며 state store는 이
|
|||||||
예약 턴은 revision/CAS와 lease를 사용합니다. API의 편집과 daemon의 실행이
|
예약 턴은 revision/CAS와 lease를 사용합니다. API의 편집과 daemon의 실행이
|
||||||
경합해도 오래된 revision이 새 queue를 덮어쓰지 않게 합니다.
|
경합해도 오래된 revision이 새 queue를 덮어쓰지 않게 합니다.
|
||||||
|
|
||||||
|
정상 gameplay 경로는 table 전체를 배타 잠그지 않습니다. 서로 다른 profile
|
||||||
|
schema의 row lock은 직접 충돌하지 않지만 다음 직렬화 지점은 남습니다.
|
||||||
|
|
||||||
|
- daemon flush마다 profile별 `turn_daemon_lease`와 단일 `world_state` 행을 갱신합니다.
|
||||||
|
- `read_model_revision`의 전역 entity와 input-event revision/CAS는 같은 profile에서 hot row가 될 수 있습니다.
|
||||||
|
- outbox dispatcher는 `FOR UPDATE SKIP LOCKED`로 claim 경쟁을 분산합니다.
|
||||||
|
- 경매, 베팅, 메시지, 장수 선택·생성은 대상 row lock 또는 advisory lock을 사용합니다.
|
||||||
|
- PostgreSQL advisory lock은 schema가 아니라 database 범위입니다. key에 profile/schema를 포함하지 않은 일부
|
||||||
|
기능별 lock은 서로 다른 profile 사이에서도 같은 key일 때 잠깐 직렬화될 수 있습니다.
|
||||||
|
|
||||||
|
월 경계 flush는 dirty world, 장수·국가·도시, 로그와 outbox를 한 transaction에
|
||||||
|
저장하므로 일반 장수 1턴보다 lock 보유 시간이 깁니다. profile별 월 경계 시각이
|
||||||
|
겹치면 row 자체는 달라도 PostgreSQL CPU/I/O, connection과 runtime memory에서
|
||||||
|
경합합니다. Migration과 `RESET`의 강한 lock은 일반 운영 중 실행하지 않고
|
||||||
|
orchestrator의 process 정지·배포 경계에서 다룹니다.
|
||||||
|
|
||||||
## 월간 경계
|
## 월간 경계
|
||||||
|
|
||||||
Calendar handler는 turn time이 월 경계를 지날 때 scenario event table의
|
Calendar handler는 turn time이 월 경계를 지날 때 scenario event table의
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ versioned data-directory 계약에 맞춰 volume은 `/var/lib/postgresql`에 붙
|
|||||||
`15442/16379`이며 `CAPACITY_POSTGRES_PORT`/`CAPACITY_REDIS_PORT`로 충돌 없이 바꿀 수 있다. `prepare`는
|
`15442/16379`이며 `CAPACITY_POSTGRES_PORT`/`CAPACITY_REDIS_PORT`로 충돌 없이 바꿀 수 있다. `prepare`는
|
||||||
PostgreSQL password, API token/image secret과 정확한 URL을 무작위 생성해 Git ignored `secrets/`의 새
|
PostgreSQL password, API token/image secret과 정확한 URL을 무작위 생성해 Git ignored `secrets/`의 새
|
||||||
파일 세 개에 `0600`으로 저장한다. 기존 파일을 덮어쓰거나 비밀값을 stdout에 쓰지 않는다.
|
파일 세 개에 `0600`으로 저장한다. 기존 파일을 덮어쓰거나 비밀값을 stdout에 쓰지 않는다.
|
||||||
|
기존 fixture volume을 보존하면서 별도 실행이 필요하면
|
||||||
|
`CAPACITY_COMPOSE_PROJECT_NAME`과 `CAPACITY_POSTGRES_VOLUME_NAME`을 함께 고유하게 지정한다.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pnpm --filter @sammo-ts/load-tests prepare:capacity \
|
pnpm --filter @sammo-ts/load-tests prepare:capacity \
|
||||||
@@ -152,7 +154,34 @@ pnpm --filter @sammo-ts/game-engine profile:npc-capacity-1200
|
|||||||
이 프로필은 자연 통일 소요시간 시험이 아니라 고정 1개월 engine 처리량 시험이다. 기존
|
이 프로필은 자연 통일 소요시간 시험이 아니라 고정 1개월 engine 처리량 시험이다. 기존
|
||||||
`profile:npc-unification-timing`의 무보정 자연 진행 의미는 바꾸지 않는다.
|
`profile:npc-unification-timing`의 무보정 자연 진행 의미는 바꾸지 않는다.
|
||||||
|
|
||||||
### 4. 명시적 cleanup
|
### 4. E2 PostgreSQL flush와 profile 간 경합
|
||||||
|
|
||||||
|
`measure-turn-flush`는 검증된 fixture를 production loader로 읽고 daemon lease/fencing, 장수 턴,
|
||||||
|
dirty-state transaction, journal/outbox와 commit 이후 Redis 발행을 거쳐 정확히 한 월 경계를 실행한다.
|
||||||
|
정상 realtime daemon처럼 장수는 한 transaction에 하나씩 commit한다. 권위 순서는 `turn_tick`이며,
|
||||||
|
JavaScript `Date`의 밀리초보다 세밀한 tick을 포함하도록 cutoff를 보정한다.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm --filter @sammo-ts/load-tests measure-turn-flush \
|
||||||
|
--config tools/load-tests/config/300-users-900-npcs-5m.json \
|
||||||
|
--confirm load_capacity_300_900_5m \
|
||||||
|
--output tools/load-tests/results/turn-flush.json
|
||||||
|
```
|
||||||
|
|
||||||
|
결과에는 장수 transaction과 월 transaction/Redis 발행 latency, 처리량, 시작·종료 장수 수,
|
||||||
|
process CPU/RSS/event-loop lag, database-wide `pg_stat_database` delta와 connection/active/lock-wait 최대값이
|
||||||
|
들어간다. PostgreSQL delta에는 별도 observer sampler의 read transaction도 포함되므로 transaction 수를
|
||||||
|
daemon commit 수와 동일하다고 해석하지 않는다.
|
||||||
|
|
||||||
|
`nya`와 `pya` 동시 1분 경합용 config는 각각 Redis DB 14/13과 별도 `load_` schema를 사용한다.
|
||||||
|
같은 PostgreSQL/CPU에서 두 fixture를 seed한 뒤 두 `measure-turn-flush` process를 동시에 시작하여
|
||||||
|
schema 간 row-lock 격리와 공유 CPU/I/O/connection 경합을 확인한다. 이 실행은 실제 운영 profile이나
|
||||||
|
공개 URL을 대상으로 하지 않는다.
|
||||||
|
|
||||||
|
- `tools/load-tests/config/nya-10-users-800-npcs-1m.json`
|
||||||
|
- `tools/load-tests/config/pya-10-users-800-npcs-1m.json`
|
||||||
|
|
||||||
|
### 5. 명시적 cleanup
|
||||||
|
|
||||||
token 파일은 별도로 안전하게 삭제하고, fixture schema/Redis token은 schema명을 그대로 확인 인자로 주어
|
token 파일은 별도로 안전하게 삭제하고, fixture schema/Redis token은 schema명을 그대로 확인 인자로 주어
|
||||||
정리한다. named volume은 보존한다. 데이터 폐기가 필요하지 않으면 이 명령을 실행하지 않는다.
|
정리한다. named volume은 보존한다. 데이터 폐기가 필요하지 않으면 이 명령을 실행하지 않는다.
|
||||||
@@ -166,9 +195,11 @@ docker compose -f tools/load-tests/compose.capacity.yml down
|
|||||||
|
|
||||||
## 아직 남은 측정 경계
|
## 아직 남은 측정 경계
|
||||||
|
|
||||||
- `seed`/`verify-fixture`는 실제 PostgreSQL schema와 Redis access-token 상태를 만든다. 그러나 E2의 daemon
|
- `measure-turn-flush`는 한 달을 wall-clock보다 빠르게 replay하는 처리량 시험이다. 실제 schedule lag,
|
||||||
fast-forward, 한 달치 PostgreSQL flush/outbox publish, schedule lag와 DB statement count를 하나로
|
장시간 pool wait와 autovacuum/checkpoint 영향을 보려면 profile별 속도로 pacing한 soak가 별도로 필요하다.
|
||||||
계측하는 실행기는 아직 없다. 따라서 E1이나 API/SSE driver 결과를 E2 합격으로 대체하지 않는다.
|
- 월 경계 latency는 실행당 표본이 하나다. scenario 진행 시점과 월별 event 차이를 포괄하지 않는다.
|
||||||
|
- 두 1분 profile 동시 실행은 최악 turn-rate 조합의 국소 증거이며, 여섯 profile의 전체 PM2 RSS,
|
||||||
|
Gateway·worker connection과 운영 container cgroup을 재현하지 않는다.
|
||||||
- own/global phase의 mutation stimulus는 driver가 만들지 않는다. 격리 runtime의 실제 engine/API mutation과
|
- own/global phase의 mutation stimulus는 driver가 만들지 않는다. 격리 runtime의 실제 engine/API mutation과
|
||||||
함께 실행하지 않았다면 A2/A3/M1 전체 합격으로 보고하지 않는다.
|
함께 실행하지 않았다면 A2/A3/M1 전체 합격으로 보고하지 않는다.
|
||||||
- 이 repository에서 실행한 로컬 E1 수치는 source-tree 회귀 근거다. dev-sam2026 동급 4 CPU/8 GiB container
|
- 이 repository에서 실행한 로컬 E1 수치는 source-tree 회귀 근거다. dev-sam2026 동급 4 CPU/8 GiB container
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: sammo-capacity-fixture
|
name: ${CAPACITY_COMPOSE_PROJECT_NAME:-sammo-capacity-fixture}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -39,4 +39,4 @@ secrets:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
capacity_postgres_data:
|
capacity_postgres_data:
|
||||||
name: sammo_capacity_fixture_postgres_data
|
name: ${CAPACITY_POSTGRES_VOLUME_NAME:-sammo_capacity_fixture_postgres_data}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./load-test.schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"name": "nya-10-users-800-npcs-1m",
|
||||||
|
"target": {
|
||||||
|
"baseUrl": "http://127.0.0.1:15001",
|
||||||
|
"trpcPath": "/api/trpc",
|
||||||
|
"ssePath": "/events",
|
||||||
|
"publicProfile": false,
|
||||||
|
"allowedHosts": ["127.0.0.1", "localhost"]
|
||||||
|
},
|
||||||
|
"isolation": {
|
||||||
|
"postgresSchema": "load_capacity_nya_10_800_1m",
|
||||||
|
"redisPrefix": "load-tests:capacity-nya-10-800-1m:",
|
||||||
|
"redisDatabase": 14,
|
||||||
|
"profileName": "load-tests:capacity-nya-10-800-1m"
|
||||||
|
},
|
||||||
|
"capacity": {
|
||||||
|
"authenticatedViewers": 10,
|
||||||
|
"npcGenerals": 800,
|
||||||
|
"humanGenerals": 10,
|
||||||
|
"turnIntervalMs": 60000
|
||||||
|
},
|
||||||
|
"runtimeMetadata": {
|
||||||
|
"fixtureSha256": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"imageDigest": "replace-before-measurement",
|
||||||
|
"postgresVersion": "replace-before-measurement",
|
||||||
|
"redisVersion": "replace-before-measurement"
|
||||||
|
},
|
||||||
|
"phases": [
|
||||||
|
{
|
||||||
|
"name": "idle-calibration",
|
||||||
|
"kind": "idle",
|
||||||
|
"durationMs": 1000,
|
||||||
|
"sseConnections": 10,
|
||||||
|
"requestIntervalMs": null,
|
||||||
|
"operations": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "own-calibration",
|
||||||
|
"kind": "own",
|
||||||
|
"durationMs": 1000,
|
||||||
|
"sseConnections": 10,
|
||||||
|
"requestIntervalMs": 1000,
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"name": "own-context",
|
||||||
|
"procedure": "dashboard.getContextBundleDelta",
|
||||||
|
"type": "query",
|
||||||
|
"weight": 1,
|
||||||
|
"input": {
|
||||||
|
"include": { "context": true, "commandTable": true, "boardAccess": true },
|
||||||
|
"forceSnapshot": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "global-calibration",
|
||||||
|
"kind": "global",
|
||||||
|
"durationMs": 1000,
|
||||||
|
"sseConnections": 10,
|
||||||
|
"requestIntervalMs": 1000,
|
||||||
|
"operations": [
|
||||||
|
{ "name": "global-front", "procedure": "general.getFrontStatus", "type": "query", "weight": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mixed-calibration",
|
||||||
|
"kind": "mixed",
|
||||||
|
"durationMs": 1000,
|
||||||
|
"sseConnections": 10,
|
||||||
|
"requestIntervalMs": 1000,
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"name": "own-context",
|
||||||
|
"procedure": "dashboard.getContextBundleDelta",
|
||||||
|
"type": "query",
|
||||||
|
"weight": 1,
|
||||||
|
"input": {
|
||||||
|
"include": { "context": true, "commandTable": true, "boardAccess": true },
|
||||||
|
"forceSnapshot": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ "name": "global-front", "procedure": "general.getFrontStatus", "type": "query", "weight": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./load-test.schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"name": "pya-10-users-800-npcs-1m",
|
||||||
|
"target": {
|
||||||
|
"baseUrl": "http://127.0.0.1:15001",
|
||||||
|
"trpcPath": "/api/trpc",
|
||||||
|
"ssePath": "/events",
|
||||||
|
"publicProfile": false,
|
||||||
|
"allowedHosts": ["127.0.0.1", "localhost"]
|
||||||
|
},
|
||||||
|
"isolation": {
|
||||||
|
"postgresSchema": "load_capacity_pya_10_800_1m",
|
||||||
|
"redisPrefix": "load-tests:capacity-pya-10-800-1m:",
|
||||||
|
"redisDatabase": 13,
|
||||||
|
"profileName": "load-tests:capacity-pya-10-800-1m"
|
||||||
|
},
|
||||||
|
"capacity": {
|
||||||
|
"authenticatedViewers": 10,
|
||||||
|
"npcGenerals": 800,
|
||||||
|
"humanGenerals": 10,
|
||||||
|
"turnIntervalMs": 60000
|
||||||
|
},
|
||||||
|
"runtimeMetadata": {
|
||||||
|
"fixtureSha256": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"imageDigest": "replace-before-measurement",
|
||||||
|
"postgresVersion": "replace-before-measurement",
|
||||||
|
"redisVersion": "replace-before-measurement"
|
||||||
|
},
|
||||||
|
"phases": [
|
||||||
|
{
|
||||||
|
"name": "idle-calibration",
|
||||||
|
"kind": "idle",
|
||||||
|
"durationMs": 1000,
|
||||||
|
"sseConnections": 10,
|
||||||
|
"requestIntervalMs": null,
|
||||||
|
"operations": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "own-calibration",
|
||||||
|
"kind": "own",
|
||||||
|
"durationMs": 1000,
|
||||||
|
"sseConnections": 10,
|
||||||
|
"requestIntervalMs": 1000,
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"name": "own-context",
|
||||||
|
"procedure": "dashboard.getContextBundleDelta",
|
||||||
|
"type": "query",
|
||||||
|
"weight": 1,
|
||||||
|
"input": {
|
||||||
|
"include": { "context": true, "commandTable": true, "boardAccess": true },
|
||||||
|
"forceSnapshot": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "global-calibration",
|
||||||
|
"kind": "global",
|
||||||
|
"durationMs": 1000,
|
||||||
|
"sseConnections": 10,
|
||||||
|
"requestIntervalMs": 1000,
|
||||||
|
"operations": [
|
||||||
|
{ "name": "global-front", "procedure": "general.getFrontStatus", "type": "query", "weight": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mixed-calibration",
|
||||||
|
"kind": "mixed",
|
||||||
|
"durationMs": 1000,
|
||||||
|
"sseConnections": 10,
|
||||||
|
"requestIntervalMs": 1000,
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"name": "own-context",
|
||||||
|
"procedure": "dashboard.getContextBundleDelta",
|
||||||
|
"type": "query",
|
||||||
|
"weight": 1,
|
||||||
|
"input": {
|
||||||
|
"include": { "context": true, "commandTable": true, "boardAccess": true },
|
||||||
|
"forceSnapshot": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ "name": "global-front", "procedure": "general.getFrontStatus", "type": "query", "weight": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"seed": "pnpm -w exec tsx tools/load-tests/src/cli.ts seed",
|
"seed": "pnpm -w exec tsx tools/load-tests/src/cli.ts seed",
|
||||||
"verify-fixture": "pnpm -w exec tsx tools/load-tests/src/cli.ts verify-fixture",
|
"verify-fixture": "pnpm -w exec tsx tools/load-tests/src/cli.ts verify-fixture",
|
||||||
"activate-coverage": "pnpm -w exec tsx tools/load-tests/src/cli.ts activate-coverage",
|
"activate-coverage": "pnpm -w exec tsx tools/load-tests/src/cli.ts activate-coverage",
|
||||||
|
"measure-turn-flush": "pnpm -w exec tsx tools/load-tests/src/cli.ts measure-turn-flush",
|
||||||
"materialize-calibration": "pnpm -w exec tsx tools/load-tests/src/cli.ts materialize-calibration",
|
"materialize-calibration": "pnpm -w exec tsx tools/load-tests/src/cli.ts materialize-calibration",
|
||||||
"cleanup": "pnpm -w exec tsx tools/load-tests/src/cli.ts cleanup",
|
"cleanup": "pnpm -w exec tsx tools/load-tests/src/cli.ts cleanup",
|
||||||
"test": "pnpm -w exec tsx --test tools/load-tests/test/*.test.ts",
|
"test": "pnpm -w exec tsx --test tools/load-tests/test/*.test.ts",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
verifyCapacityFixture,
|
verifyCapacityFixture,
|
||||||
} from './fixture.js';
|
} from './fixture.js';
|
||||||
import { describeDryRun, runLoadTest } from './runner.js';
|
import { describeDryRun, runLoadTest } from './runner.js';
|
||||||
|
import { measureTurnFlush } from './turnFlush.js';
|
||||||
|
|
||||||
type Command =
|
type Command =
|
||||||
| 'run'
|
| 'run'
|
||||||
@@ -20,12 +21,13 @@ type Command =
|
|||||||
| 'seed'
|
| 'seed'
|
||||||
| 'verify-fixture'
|
| 'verify-fixture'
|
||||||
| 'activate-coverage'
|
| 'activate-coverage'
|
||||||
|
| 'measure-turn-flush'
|
||||||
| 'materialize-calibration'
|
| 'materialize-calibration'
|
||||||
| 'cleanup';
|
| 'cleanup';
|
||||||
|
|
||||||
const usage = (): never => {
|
const usage = (): never => {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
'usage: cli.ts <validate|dry-run|run|prepare|seed|verify-fixture|activate-coverage|materialize-calibration|cleanup> --config <file> [--tokens <0600-gitignored-file>] [--output <new-json-file>] [--confirm <load_schema>]\n'
|
'usage: cli.ts <validate|dry-run|run|prepare|seed|verify-fixture|activate-coverage|measure-turn-flush|materialize-calibration|cleanup> --config <file> [--tokens <0600-gitignored-file>] [--output <new-json-file>] [--confirm <load_schema>]\n'
|
||||||
);
|
);
|
||||||
process.exit(64);
|
process.exit(64);
|
||||||
};
|
};
|
||||||
@@ -43,6 +45,7 @@ const parseArguments = (
|
|||||||
'seed',
|
'seed',
|
||||||
'verify-fixture',
|
'verify-fixture',
|
||||||
'activate-coverage',
|
'activate-coverage',
|
||||||
|
'measure-turn-flush',
|
||||||
'materialize-calibration',
|
'materialize-calibration',
|
||||||
'cleanup',
|
'cleanup',
|
||||||
].includes(command ?? '')
|
].includes(command ?? '')
|
||||||
@@ -69,6 +72,11 @@ const parseArguments = (
|
|||||||
(!values.get('--confirm') || values.has('--tokens') || values.has('--output'))
|
(!values.get('--confirm') || values.has('--tokens') || values.has('--output'))
|
||||||
)
|
)
|
||||||
usage();
|
usage();
|
||||||
|
if (
|
||||||
|
command === 'measure-turn-flush' &&
|
||||||
|
(!values.get('--confirm') || !values.get('--output') || values.has('--tokens'))
|
||||||
|
)
|
||||||
|
usage();
|
||||||
if (
|
if (
|
||||||
command === 'materialize-calibration' &&
|
command === 'materialize-calibration' &&
|
||||||
(!values.get('--output') || values.has('--tokens') || values.has('--confirm'))
|
(!values.get('--output') || values.has('--tokens') || values.has('--confirm'))
|
||||||
@@ -116,6 +124,20 @@ const main = async (): Promise<void> => {
|
|||||||
process.stdout.write(`${JSON.stringify(await activateCapacityCoverage(config, args.confirm!))}\n`);
|
process.stdout.write(`${JSON.stringify(await activateCapacityCoverage(config, args.confirm!))}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (args.command === 'measure-turn-flush') {
|
||||||
|
const output = path.resolve(args.output!);
|
||||||
|
await mkdir(path.dirname(output), { recursive: true });
|
||||||
|
const result = await measureTurnFlush({ config, confirmation: args.confirm! });
|
||||||
|
await writeFile(output, `${JSON.stringify(result, null, 2)}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
process.stdout.write(
|
||||||
|
`${JSON.stringify({ completed: true, processedGenerals: result.throughput.processedGenerals, outputWritten: true })}\n`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (args.command === 'materialize-calibration') {
|
if (args.command === 'materialize-calibration') {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTurnDaemonRuntime,
|
||||||
|
getNextTickTime,
|
||||||
|
type TurnCheckpoint,
|
||||||
|
type TurnRunResult,
|
||||||
|
} from '@sammo-ts/game-engine';
|
||||||
|
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import type { LoadConfig } from './config.js';
|
||||||
|
import { verifyCapacityFixture } from './fixture.js';
|
||||||
|
import { summarizeDistribution } from './metrics.js';
|
||||||
|
|
||||||
|
type DatabaseStatsRow = {
|
||||||
|
xactCommit: bigint;
|
||||||
|
xactRollback: bigint;
|
||||||
|
blocksRead: bigint;
|
||||||
|
blocksHit: bigint;
|
||||||
|
tuplesReturned: bigint;
|
||||||
|
tuplesFetched: bigint;
|
||||||
|
tuplesInserted: bigint;
|
||||||
|
tuplesUpdated: bigint;
|
||||||
|
tuplesDeleted: bigint;
|
||||||
|
tempFiles: bigint;
|
||||||
|
tempBytes: bigint;
|
||||||
|
deadlocks: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActivityRow = {
|
||||||
|
connections: bigint;
|
||||||
|
active: bigint;
|
||||||
|
waitingLocks: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readDatabaseStats = async (
|
||||||
|
database: ReturnType<typeof createGamePostgresConnector>['prisma']
|
||||||
|
): Promise<DatabaseStatsRow> => {
|
||||||
|
const rows = await database.$queryRaw<DatabaseStatsRow[]>(GamePrisma.sql`
|
||||||
|
SELECT
|
||||||
|
xact_commit AS "xactCommit",
|
||||||
|
xact_rollback AS "xactRollback",
|
||||||
|
blks_read AS "blocksRead",
|
||||||
|
blks_hit AS "blocksHit",
|
||||||
|
tup_returned AS "tuplesReturned",
|
||||||
|
tup_fetched AS "tuplesFetched",
|
||||||
|
tup_inserted AS "tuplesInserted",
|
||||||
|
tup_updated AS "tuplesUpdated",
|
||||||
|
tup_deleted AS "tuplesDeleted",
|
||||||
|
temp_files AS "tempFiles",
|
||||||
|
temp_bytes AS "tempBytes",
|
||||||
|
deadlocks
|
||||||
|
FROM pg_stat_database
|
||||||
|
WHERE datname = current_database()
|
||||||
|
`);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) throw new Error('pg_stat_database did not return the current database');
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtractDatabaseStats = (before: DatabaseStatsRow, after: DatabaseStatsRow): Record<string, string> =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.keys(before).map((key) => {
|
||||||
|
const name = key as keyof DatabaseStatsRow;
|
||||||
|
return [key, (after[name] - before[name]).toString()];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const readActivity = async (
|
||||||
|
database: ReturnType<typeof createGamePostgresConnector>['prisma']
|
||||||
|
): Promise<ActivityRow> => {
|
||||||
|
const rows = await database.$queryRaw<ActivityRow[]>(GamePrisma.sql`
|
||||||
|
SELECT
|
||||||
|
(SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()) AS connections,
|
||||||
|
(
|
||||||
|
SELECT count(*)
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = current_database() AND state = 'active' AND pid <> pg_backend_pid()
|
||||||
|
) AS active,
|
||||||
|
(
|
||||||
|
SELECT count(*)
|
||||||
|
FROM pg_locks
|
||||||
|
WHERE NOT granted AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||||
|
) AS "waitingLocks"
|
||||||
|
`);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) throw new Error('PostgreSQL activity sampler returned no row');
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const round = (value: number): number => Math.round(value * 1000) / 1000;
|
||||||
|
|
||||||
|
const includeSubMillisecondGameTick = (turnTime: Date): Date =>
|
||||||
|
// Game ticks are finer than JavaScript Date's millisecond precision. The
|
||||||
|
// loader projects authoritative turn_tick to a floored Date, so replaying
|
||||||
|
// that exact Date can map to a tick just before the general is due.
|
||||||
|
new Date(turnTime.getTime() + 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs exactly one logical month through the production loader, lease/fencing,
|
||||||
|
* dirty-state flush, journal/outbox and Redis publication boundaries. General
|
||||||
|
* turns are committed one at a time in chronological order, matching a healthy
|
||||||
|
* realtime daemon rather than a catch-up chunk.
|
||||||
|
*/
|
||||||
|
export const measureTurnFlush = async (options: {
|
||||||
|
config: LoadConfig;
|
||||||
|
confirmation: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}) => {
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
if (options.confirmation !== options.config.isolation.postgresSchema) {
|
||||||
|
throw new Error('turn-flush confirmation must exactly equal isolation.postgresSchema');
|
||||||
|
}
|
||||||
|
const databaseUrl = env.LOAD_TEST_DATABASE_URL;
|
||||||
|
const redisUrl = env.LOAD_TEST_REDIS_URL;
|
||||||
|
if (!databaseUrl || !redisUrl) {
|
||||||
|
throw new Error('LOAD_TEST_DATABASE_URL and LOAD_TEST_REDIS_URL are required');
|
||||||
|
}
|
||||||
|
const fixture = await verifyCapacityFixture(options.config, env);
|
||||||
|
if (!fixture.valid) throw new Error('fixture verification failed; refusing turn-flush measurement');
|
||||||
|
|
||||||
|
const observer = createGamePostgresConnector({ url: databaseUrl });
|
||||||
|
await observer.connect();
|
||||||
|
let beforeStats: DatabaseStatsRow;
|
||||||
|
try {
|
||||||
|
beforeStats = await readDatabaseStats(observer.prisma);
|
||||||
|
} catch (error) {
|
||||||
|
await observer.disconnect().catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const activity = {
|
||||||
|
samples: 0,
|
||||||
|
failures: 0,
|
||||||
|
maxConnections: 0,
|
||||||
|
maxActive: 0,
|
||||||
|
maxWaitingLocks: 0,
|
||||||
|
};
|
||||||
|
let sampling = true;
|
||||||
|
const sampleActivity = async (): Promise<void> => {
|
||||||
|
while (sampling) {
|
||||||
|
try {
|
||||||
|
const sample = await readActivity(observer.prisma);
|
||||||
|
activity.samples += 1;
|
||||||
|
activity.maxConnections = Math.max(activity.maxConnections, Number(sample.connections));
|
||||||
|
activity.maxActive = Math.max(activity.maxActive, Number(sample.active));
|
||||||
|
activity.maxWaitingLocks = Math.max(activity.maxWaitingLocks, Number(sample.waitingLocks));
|
||||||
|
} catch {
|
||||||
|
activity.failures += 1;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const activityPromise = sampleActivity();
|
||||||
|
|
||||||
|
const histogram = monitorEventLoopDelay({ resolution: 20 });
|
||||||
|
histogram.enable();
|
||||||
|
const cpuStart = process.cpuUsage();
|
||||||
|
const wallStartNs = process.hrtime.bigint();
|
||||||
|
let maxRssBytes = process.memoryUsage().rss;
|
||||||
|
const generalTransactionMs: number[] = [];
|
||||||
|
const monthlyTransactionMs: number[] = [];
|
||||||
|
const publicationMs: number[] = [];
|
||||||
|
let processedGenerals = 0;
|
||||||
|
let processedMonths = 0;
|
||||||
|
let runtime: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | null = null;
|
||||||
|
let startYearMonth: string | null = null;
|
||||||
|
let endYearMonth: string | null = null;
|
||||||
|
let initialGeneralCount: number | null = null;
|
||||||
|
let finalGeneralCount: number | null = null;
|
||||||
|
let runError: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
runtime = await createTurnDaemonRuntime({
|
||||||
|
// Omitting profileName deliberately disables the Gateway admin-action
|
||||||
|
// consumer. The load schema contains game tables only; the scoped
|
||||||
|
// profile value still isolates the lease and Redis channel.
|
||||||
|
profile: options.config.isolation.profileName,
|
||||||
|
databaseUrl,
|
||||||
|
redisUrl,
|
||||||
|
gameClockMode: 'manual',
|
||||||
|
enableDatabaseFlush: true,
|
||||||
|
enableLeaseHeartbeat: true,
|
||||||
|
databaseTransactionTimeoutMs: 30_000,
|
||||||
|
});
|
||||||
|
const initialState = runtime.world.getState();
|
||||||
|
initialGeneralCount = runtime.world.listGenerals().length;
|
||||||
|
startYearMonth = `${initialState.currentYear}-${String(initialState.currentMonth).padStart(2, '0')}`;
|
||||||
|
const tickMinutes = Math.max(1, Math.round(initialState.tickSeconds / 60));
|
||||||
|
const boundary = getNextTickTime(initialState.lastTurnTime, tickMinutes);
|
||||||
|
let checkpoint: TurnCheckpoint | undefined = await runtime.stateStore.loadCheckpoint();
|
||||||
|
|
||||||
|
const execute = async (target: Date, maxGenerals: number): Promise<TurnRunResult> => {
|
||||||
|
const started = performance.now();
|
||||||
|
const result = await runtime!.stateManager.transaction(async () => {
|
||||||
|
await runtime!.stateStore.advanceGameClockTo(target, new Date());
|
||||||
|
const next = await runtime!.processor.run(
|
||||||
|
target,
|
||||||
|
{ budgetMs: 30_000, maxGenerals, catchUpCap: 1 },
|
||||||
|
checkpoint
|
||||||
|
);
|
||||||
|
await runtime!.stateStore.saveLastTurnTime(new Date(next.lastTurnTime));
|
||||||
|
await runtime!.stateStore.saveCheckpoint(next.checkpoint);
|
||||||
|
await runtime!.hooks?.flushChanges?.(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const transactionMs = performance.now() - started;
|
||||||
|
checkpoint = result.checkpoint;
|
||||||
|
maxRssBytes = Math.max(maxRssBytes, process.memoryUsage().rss);
|
||||||
|
const publishStarted = performance.now();
|
||||||
|
await runtime!.hooks?.publishEvents?.(result);
|
||||||
|
publicationMs.push(performance.now() - publishStarted);
|
||||||
|
if (result.processedTurns > 0) monthlyTransactionMs.push(transactionMs);
|
||||||
|
else generalTransactionMs.push(transactionMs);
|
||||||
|
processedGenerals += result.processedGenerals;
|
||||||
|
processedMonths += result.processedTurns;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const nextGeneral = await runtime.stateStore.loadNextGeneralTurnTime();
|
||||||
|
if (!nextGeneral || nextGeneral.getTime() >= boundary.getTime()) break;
|
||||||
|
const result = await execute(includeSubMillisecondGameTick(nextGeneral), 1);
|
||||||
|
if (result.processedGenerals !== 1 || result.processedTurns !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`chronological turn-flush run expected one general and zero months; got ${result.processedGenerals} generals and ${result.processedTurns} months`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthly = await execute(boundary, 200);
|
||||||
|
if (monthly.processedTurns !== 1) {
|
||||||
|
throw new Error('turn-flush measurement did not cross exactly one monthly boundary');
|
||||||
|
}
|
||||||
|
const finalState = runtime.world.getState();
|
||||||
|
finalGeneralCount = runtime.world.listGenerals().length;
|
||||||
|
endYearMonth = `${finalState.currentYear}-${String(finalState.currentMonth).padStart(2, '0')}`;
|
||||||
|
} catch (error) {
|
||||||
|
runError = error;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await runtime?.close();
|
||||||
|
} catch (error) {
|
||||||
|
runError ??= error;
|
||||||
|
}
|
||||||
|
sampling = false;
|
||||||
|
try {
|
||||||
|
await activityPromise;
|
||||||
|
} catch (error) {
|
||||||
|
runError ??= error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (runError !== undefined) {
|
||||||
|
histogram.disable();
|
||||||
|
await observer.disconnect().catch(() => undefined);
|
||||||
|
throw runError;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
startYearMonth === null ||
|
||||||
|
endYearMonth === null ||
|
||||||
|
initialGeneralCount === null ||
|
||||||
|
finalGeneralCount === null
|
||||||
|
) {
|
||||||
|
histogram.disable();
|
||||||
|
await observer.disconnect().catch(() => undefined);
|
||||||
|
throw new Error('turn-flush measurement completed without a full result');
|
||||||
|
}
|
||||||
|
|
||||||
|
let afterStats: DatabaseStatsRow;
|
||||||
|
try {
|
||||||
|
afterStats = await readDatabaseStats(observer.prisma);
|
||||||
|
} catch (error) {
|
||||||
|
histogram.disable();
|
||||||
|
await observer.disconnect().catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await observer.disconnect();
|
||||||
|
histogram.disable();
|
||||||
|
const elapsedMs = Number(process.hrtime.bigint() - wallStartNs) / 1_000_000;
|
||||||
|
const cpu = process.cpuUsage(cpuStart);
|
||||||
|
const cpuMs = (cpu.user + cpu.system) / 1_000;
|
||||||
|
const fromNs = (value: number): number => (Number.isFinite(value) ? round(value / 1_000_000) : 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
fixture: {
|
||||||
|
name: options.config.name,
|
||||||
|
fixtureSha256: fixture.fixtureSha256,
|
||||||
|
capacity: options.config.capacity,
|
||||||
|
},
|
||||||
|
mode: 'chronological-one-general-per-transaction-plus-month-boundary',
|
||||||
|
startYearMonth,
|
||||||
|
endYearMonth,
|
||||||
|
elapsedMs: round(elapsedMs),
|
||||||
|
throughput: {
|
||||||
|
generalTurnsPerSecond: round(processedGenerals / Math.max(elapsedMs / 1_000, 0.001)),
|
||||||
|
processedGenerals,
|
||||||
|
processedMonths,
|
||||||
|
},
|
||||||
|
population: {
|
||||||
|
initialGenerals: initialGeneralCount,
|
||||||
|
finalGenerals: finalGeneralCount,
|
||||||
|
generalDelta: finalGeneralCount - initialGeneralCount,
|
||||||
|
},
|
||||||
|
latencyMs: {
|
||||||
|
generalTransaction: summarizeDistribution(generalTransactionMs),
|
||||||
|
monthlyTransaction: summarizeDistribution(monthlyTransactionMs),
|
||||||
|
redisPublication: summarizeDistribution(publicationMs),
|
||||||
|
},
|
||||||
|
postgres: {
|
||||||
|
statsScope: 'database-wide-including-observer-sampler',
|
||||||
|
statsDelta: subtractDatabaseStats(beforeStats, afterStats),
|
||||||
|
activity,
|
||||||
|
},
|
||||||
|
process: {
|
||||||
|
cpuPercentOfOneCore: round((cpuMs / Math.max(elapsedMs, 1)) * 100),
|
||||||
|
maxRssBytes,
|
||||||
|
eventLoopLagMs: {
|
||||||
|
min: fromNs(histogram.min),
|
||||||
|
max: fromNs(histogram.max),
|
||||||
|
mean: fromNs(histogram.mean),
|
||||||
|
p50: fromNs(histogram.percentile(50)),
|
||||||
|
p95: fromNs(histogram.percentile(95)),
|
||||||
|
p99: fromNs(histogram.percentile(99)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
measuredAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -6,6 +6,10 @@ import test from 'node:test';
|
|||||||
import { assertRuntimeMetadataFinalized, canonicalJson, expandWeightedOperations, loadTokens, validateLoadConfig } from '../src/config.js';
|
import { assertRuntimeMetadataFinalized, canonicalJson, expandWeightedOperations, loadTokens, validateLoadConfig } from '../src/config.js';
|
||||||
|
|
||||||
const samplePath = new URL('../config/300-users-900-npcs-5m.json', import.meta.url);
|
const samplePath = new URL('../config/300-users-900-npcs-5m.json', import.meta.url);
|
||||||
|
const oneMinuteProfilePaths = [
|
||||||
|
new URL('../config/nya-10-users-800-npcs-1m.json', import.meta.url),
|
||||||
|
new URL('../config/pya-10-users-800-npcs-1m.json', import.meta.url),
|
||||||
|
];
|
||||||
|
|
||||||
void test('the 300 viewer, 900 NPC, five-minute sample validates', async () => {
|
void test('the 300 viewer, 900 NPC, five-minute sample validates', async () => {
|
||||||
const config = validateLoadConfig(JSON.parse(await readFile(samplePath, 'utf8')));
|
const config = validateLoadConfig(JSON.parse(await readFile(samplePath, 'utf8')));
|
||||||
@@ -18,6 +22,24 @@ void test('the 300 viewer, 900 NPC, five-minute sample validates', async () => {
|
|||||||
assert.deepEqual(new Set(config.phases.map((phase) => phase.kind)), new Set(['idle', 'own', 'global', 'mixed']));
|
assert.deepEqual(new Set(config.phases.map((phase) => phase.kind)), new Set(['idle', 'own', 'global', 'mixed']));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('nya and pya one-minute profiles use distinct database and Redis isolation', async () => {
|
||||||
|
const configs = await Promise.all(
|
||||||
|
oneMinuteProfilePaths.map(async (configPath) =>
|
||||||
|
validateLoadConfig(JSON.parse(await readFile(configPath, 'utf8')))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
configs.map((config) => config.capacity),
|
||||||
|
[
|
||||||
|
{ authenticatedViewers: 10, npcGenerals: 800, humanGenerals: 10, turnIntervalMs: 60_000 },
|
||||||
|
{ authenticatedViewers: 10, npcGenerals: 800, humanGenerals: 10, turnIntervalMs: 60_000 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(new Set(configs.map((config) => config.isolation.postgresSchema)).size, 2);
|
||||||
|
assert.equal(new Set(configs.map((config) => config.isolation.redisDatabase)).size, 2);
|
||||||
|
assert.equal(new Set(configs.map((config) => config.isolation.profileName)).size, 2);
|
||||||
|
});
|
||||||
|
|
||||||
void test('validation rejects public, non-allowlisted, and mutating targets', async () => {
|
void test('validation rejects public, non-allowlisted, and mutating targets', async () => {
|
||||||
const raw = JSON.parse(await readFile(samplePath, 'utf8')) as Record<string, any>;
|
const raw = JSON.parse(await readFile(samplePath, 'utf8')) as Record<string, any>;
|
||||||
raw.target.publicProfile = true;
|
raw.target.publicProfile = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user