merge: 최신 main을 tRPC JSON 본문 전송에 통합

This commit is contained in:
2026-08-17 11:12:09 +00:00
49 changed files with 1182 additions and 162 deletions
+20 -40
View File
@@ -7,6 +7,7 @@ import type { TournamentState } from '../../tournament/types.js';
import { TournamentStore } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js';
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
@@ -412,52 +413,31 @@ export const tournamentRouter = router({
});
}
const settingResult = await ctx.turnDaemon.requestCommand({
type: 'setMySetting',
generalId: general.id,
settings: { tnmt: 1 },
const meta = asRecord(general.meta);
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
const applicant = assignManualApplicantGroup({
state,
baseSeed: String(asRecord(worldState?.meta).hiddenSeed ?? 'tournament'),
current: participants,
applicant: {
id: general.id,
name: general.name,
leadership: general.leadership,
strength: general.strength,
intel: general.intel,
level,
},
});
if (!settingResult || settingResult.type !== 'setMySetting' || !settingResult.ok) {
const next = participants.concat(applicant);
try {
await store.setParticipants(next);
} catch (error) {
await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
reason: 'tournamentJoinRollback',
adjustments: [{ generalId: general.id, goldDelta: develCost }],
});
throw new TRPCError({
code: 'BAD_REQUEST',
message:
settingResult && settingResult.type === 'setMySetting'
? (settingResult.reason ?? '요청에 실패했습니다.')
: 'Unexpected response',
});
}
const meta = asRecord(general.meta);
const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
const next = participants.concat({
id: general.id,
name: general.name,
leadership: general.leadership,
strength: general.strength,
intel: general.intel,
level,
});
try {
await store.setParticipants(next);
} catch (error) {
await Promise.all([
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources',
reason: 'tournamentJoinRollback',
adjustments: [{ generalId: general.id, goldDelta: develCost }],
}),
ctx.turnDaemon.requestCommand({
type: 'setMySetting',
generalId: general.id,
settings: { tnmt: 0 },
}),
]);
throw error;
}
return { ok: true, count: next.length };
+29 -14
View File
@@ -28,13 +28,14 @@ import {
repeatNationTurns,
setGeneralTurn,
setGeneralTurns,
setNationTurn,
setNationTurns,
setNationTurnAtCurrentPosition,
setNationTurnsAtCurrentPositions,
shiftGeneralTurns,
shiftNationTurns,
} from '../../turns/reservedTurns.js';
import { getOwnedGeneral } from '../shared/general.js';
import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js';
import { buildRefGeneralTargetOptions } from '../../turns/commandTargets.js';
const zPushAmount = z
.number()
@@ -181,9 +182,15 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
orderBy: { id: 'asc' },
}),
ctx.db.general.findMany({
where: { npcState: { lt: 2 } },
select: { id: true, name: true, nationId: true, cityId: true },
orderBy: { id: 'asc' },
select: {
id: true,
name: true,
nationId: true,
cityId: true,
npcState: true,
officerLevel: true,
},
orderBy: [{ npcState: 'asc' }, { name: 'asc' }, { id: 'asc' }],
}),
environmentPromise,
loadBattleSimTraitOptions(),
@@ -192,7 +199,13 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
]);
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
const cityById = new Map(cities.map((entry) => [entry.id, entry]));
const generalTargetOptions = buildRefGeneralTargetOptions({
actorId: general.id,
actorNationId: general.nationId,
generals,
nationNames: new Map(nations.map((entry) => [entry.id, entry.name])),
cityNames: new Map(cities.map((entry) => [entry.id, entry.name])),
});
const items: TurnCommandInputOptions['items'] = {
horse: [{ value: 'None', label: '판매/해제' }],
weapon: [{ value: 'None', label: '판매/해제' }],
@@ -226,12 +239,8 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
label: entry.name,
color: entry.color,
})),
generals: generals.map((entry) => ({
value: entry.id,
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무소속'} · ${
cityById.get(entry.cityId)?.name ?? '재야'
})`,
})),
generals: generalTargetOptions.generals,
generalTargets: generalTargetOptions.generalTargets,
crewTypes: (environment.unitSet.crewTypes ?? [])
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
.map((entry) => ({ value: entry.id, label: entry.name })),
@@ -446,7 +455,7 @@ export const turnsRouter = router({
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
const snapshot = await mutateReservedTurns(() =>
setNationTurn(
setNationTurnAtCurrentPosition(
ctx.db,
general.nationId,
general.officerLevel,
@@ -559,7 +568,13 @@ export const turnsRouter = router({
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
}
const snapshot = await mutateReservedTurns(() =>
setNationTurns(ctx.db, general.nationId, general.officerLevel, updates, input.expectedRevision)
setNationTurnsAtCurrentPositions(
ctx.db,
general.nationId,
general.officerLevel,
updates,
input.expectedRevision
)
);
return { ok: true, ...snapshot };
}),
@@ -155,6 +155,60 @@ export const assignGroupSlots = (
});
};
/**
* Ref assigns a manual applicant to one uniformly selected non-full preliminary
* group as part of the join request. Keeping that assignment in the persisted
* participant projection lets the applicant see the group immediately while
* the later participant-fill pass can still balance automatic applicants.
*/
export const assignManualApplicantGroup = (options: {
state: TournamentState;
baseSeed: string;
current: TournamentParticipantEntry[];
applicant: TournamentParticipantEntry;
groupCount?: number;
groupSize?: number;
}): TournamentParticipantEntry => {
const groupCount = options.groupCount ?? 8;
const groupSize = options.groupSize ?? 8;
const groupCounts = Array.from({ length: groupCount }, () => 0);
for (const participant of options.current) {
const groupId = participant.groupId;
if (groupId !== undefined && groupId >= 0 && groupId < groupCount) {
groupCounts[groupId] = (groupCounts[groupId] ?? 0) + 1;
}
}
const openGroupIds = groupCounts.flatMap((count, groupId) => (count < groupSize ? [groupId] : []));
if (openGroupIds.length === 0) {
throw new Error('참가 인원이 가득 찼습니다.');
}
const rng = createTournamentRng(options.baseSeed, {
openYear: options.state.openYear,
openMonth: options.state.openMonth,
stage: 1,
phase: options.state.phase,
matchIndex: options.applicant.id,
participantIndex: options.current.length,
extraSeed: `manual-group:${options.current.map((entry) => entry.id).join('-')}:${openGroupIds.join('-')}`,
});
const groupId = rng.choice(openGroupIds);
return {
...options.applicant,
groupId,
groupNo: groupCounts[groupId] ?? 0,
win: 0,
draw: 0,
lose: 0,
gl: 0,
seedRank: 0,
finalRank: 0,
};
};
const selectWeighted = <T>(rng: ReturnType<typeof createTournamentRng>, pool: Array<{ item: T; weight: number }>): T =>
rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight]));
+1
View File
@@ -71,6 +71,7 @@ export interface TurnCommandInputOptions {
cities: TurnCommandOption[];
nations: TurnCommandOption[];
generals: TurnCommandOption[];
generalTargets?: Record<string, TurnCommandOption[]>;
crewTypes: TurnCommandOption[];
armTypes: TurnCommandOption[];
nationTypes: TurnCommandOption[];
+1
View File
@@ -771,6 +771,7 @@ export const buildTurnCommandTable = async (options: {
cities: [],
nations: [],
generals: [],
generalTargets: {},
crewTypes: [],
armTypes: [],
nationTypes: [],
+55
View File
@@ -0,0 +1,55 @@
import type { TurnCommandOption } from './commandInput.js';
export interface GeneralTargetSource {
id: number;
name: string;
nationId: number;
cityId: number;
npcState: number;
officerLevel: number;
}
export interface RefGeneralTargetOptions {
generals: TurnCommandOption[];
generalTargets: Record<string, TurnCommandOption[]>;
}
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] as const;
/** Ref 각 처리 화면의 SELECT 조건을 공통 command table의 명령별 option으로 투영한다. */
export const buildRefGeneralTargetOptions = (options: {
actorId: number;
actorNationId: number;
generals: readonly GeneralTargetSource[];
nationNames: ReadonlyMap<number, string>;
cityNames: ReadonlyMap<number, string>;
}): RefGeneralTargetOptions => {
const toOption = (entry: GeneralTargetSource): TurnCommandOption => ({
value: entry.id,
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${
options.cityNames.get(entry.cityId) ?? '재야'
})`,
});
const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
options.generals.filter(predicate).map(toOption);
const sameNation = project((entry) => entry.nationId === options.actorNationId);
const generalTargets: Record<string, TurnCommandOption[]> = {};
for (const action of SAME_NATION_GENERAL_COMMANDS) generalTargets[action] = sameNation;
for (const action of SAME_NATION_NATION_COMMANDS) generalTargets[action] = sameNation;
generalTargets.che_선양 = project(
(entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId
);
generalTargets.che_등용 = project(
(entry) => entry.npcState < 2 && entry.officerLevel !== 12 && entry.id !== options.actorId
);
generalTargets.che_장수대상임관 = project((entry) => entry.id !== options.actorId);
return {
// 기존 profile의 공통 fallback은 유저장 목록을 유지한다.
generals: project((entry) => entry.npcState < 2),
generalTargets,
};
};
+43
View File
@@ -398,6 +398,32 @@ export const setNationTurns = async (
return { revision, turns: serializeTurnList(turns) };
};
/**
* 국가 턴 입력은 화면을 연 뒤 daemon이 선두 턴을 소비했더라도 사용자가 고른
* 슬롯 번호를 현재 큐에 적용한다. 큐 lease가 실제로 잡혀 있는 충돌은 그대로
* 거절하고, revision이 앞으로 진행한 경우에만 새 revision으로 재기준화한다.
*/
export const setNationTurnsAtCurrentPositions = async (
db: DatabaseClient,
nationId: number,
officerLevel: number,
updates: readonly ReservedTurnUpdate[],
expectedRevision: number
): Promise<ReservedTurnSnapshot> => {
let revision = expectedRevision;
for (let attempt = 0; attempt < 8; attempt += 1) {
try {
return await setNationTurns(db, nationId, officerLevel, updates, revision);
} catch (error) {
if (!(error instanceof ReservedTurnRevisionConflictError) || error.currentRevision === revision) {
throw error;
}
revision = error.currentRevision;
}
}
throw new ReservedTurnRevisionConflictError(revision, revision);
};
export const setNationTurn = async (
db: DatabaseClient,
nationId: number,
@@ -409,6 +435,23 @@ export const setNationTurn = async (
): Promise<ReservedTurnSnapshot> =>
setNationTurns(db, nationId, officerLevel, [{ turnIndices: [turnIndex], action, args }], expectedRevision);
export const setNationTurnAtCurrentPosition = async (
db: DatabaseClient,
nationId: number,
officerLevel: number,
turnIndex: number,
action: string,
args: unknown,
expectedRevision: number
): Promise<ReservedTurnSnapshot> =>
setNationTurnsAtCurrentPositions(
db,
nationId,
officerLevel,
[{ turnIndices: [turnIndex], action, args }],
expectedRevision
);
export const shiftNationTurns = async (
db: DatabaseClient,
nationId: number,
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { buildRefGeneralTargetOptions, type GeneralTargetSource } from '../src/turns/commandTargets.js';
const general = (overrides: Partial<GeneralTargetSource>): GeneralTargetSource => ({
id: 1,
name: '본인',
nationId: 1,
cityId: 10,
npcState: 0,
officerLevel: 5,
...overrides,
});
describe('Ref command general targets', () => {
const sources = [
general({}),
general({ id: 2, name: '아국유저', officerLevel: 12 }),
general({ id: 3, name: '아국NPC', npcState: 2, officerLevel: 0 }),
general({ id: 4, name: '타국유저', nationId: 2, cityId: 20, officerLevel: 0 }),
general({ id: 5, name: '타국NPC', nationId: 2, cityId: 20, npcState: 3, officerLevel: 0 }),
];
const result = buildRefGeneralTargetOptions({
actorId: 1,
actorNationId: 1,
generals: sources,
nationNames: new Map([
[1, '아국'],
[2, '타국'],
]),
cityNames: new Map([
[10, '업'],
[20, '허창'],
]),
});
const ids = (action: string) => result.generalTargets[action]?.map((entry) => entry.value);
it('includes user and NPC generals of the same nation for every Ref nation personnel command', () => {
for (const action of ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시']) {
expect(ids(action)).toEqual([1, 2, 3]);
}
});
it('preserves the distinct Ref filters for gift, abdication, recruitment, and target-based joining', () => {
expect(ids('che_증여')).toEqual([1, 2, 3]);
expect(ids('che_선양')).toEqual([2, 3]);
expect(ids('che_등용')).toEqual([4]);
expect(ids('che_장수대상임관')).toEqual([2, 3, 4, 5]);
expect(result.generals.map((entry) => entry.value)).toEqual([1, 2, 4]);
});
});
+89 -1
View File
@@ -10,7 +10,9 @@ import {
setGeneralTurn,
setGeneralTurns,
setNationTurn,
setNationTurnAtCurrentPosition,
setNationTurns,
setNationTurnsAtCurrentPositions,
shiftGeneralTurns,
shiftNationTurns,
ReservedTurnRevisionConflictError,
@@ -195,7 +197,7 @@ const buildDb = (autorunLimit: number | null = null) => {
},
} as unknown as DatabaseClient;
return { db };
return { db, nationTurns, nationRevisions };
};
describe('reservedTurns', () => {
@@ -343,6 +345,61 @@ describe('reservedTurns', () => {
expect(noOpPush).toEqual(repeated);
});
it('rebases stale nation slot input onto the current queue after a turn advances', async () => {
const { db, nationTurns, nationRevisions } = buildDb();
const seeded = await setNationTurns(
db,
6,
12,
[
{ turnIndices: [0], action: 'che_증축', args: {} },
{ turnIndices: [1], action: 'che_감축', args: {} },
{ turnIndices: [2], action: 'che_천도', args: { destCityId: 3 } },
],
0
);
expect(seeded.revision).toBe(1);
// daemon이 한 턴을 소비한 뒤의 현재 큐를 모사한다.
nationRevisions.set('6:12', 2);
nationTurns.set('6:12', [
{
id: 1,
nationId: 6,
officerLevel: 12,
turnIdx: 0,
actionCode: 'che_감축',
arg: {},
createdAt: new Date(),
},
{
id: 2,
nationId: 6,
officerLevel: 12,
turnIdx: 1,
actionCode: 'che_천도',
arg: { destCityId: 3 },
createdAt: new Date(),
},
]);
const result = await setNationTurnsAtCurrentPositions(
db,
6,
12,
[{ turnIndices: [2], action: 'che_포상', args: { destGeneralId: 77, amount: 100, isGold: true } }],
1
);
expect(result.revision).toBe(3);
expect(result.turns[0]?.action).toBe('che_감축');
expect(result.turns[1]?.action).toBe('che_천도');
expect(result.turns[2]).toMatchObject({
action: 'che_포상',
args: { destGeneralId: 77, amount: 100, isGold: true },
});
});
it('rejects an API writer while the daemon holds the queue lease without touching turns', async () => {
const deleteMany = vi.fn(async () => ({}));
const createMany = vi.fn(async () => ({}));
@@ -371,4 +428,35 @@ describe('reservedTurns', () => {
expect(deleteMany).not.toHaveBeenCalled();
expect(createMany).not.toHaveBeenCalled();
});
it('does not rebase a current-position nation write while the daemon lease holds the same revision', async () => {
const deleteMany = vi.fn(async () => ({}));
const createMany = vi.fn(async () => ({}));
const db = {
nationTurnRevision: {
updateMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
findUnique: vi.fn(async () => ({
nationId: 6,
officerLevel: 12,
revision: 4,
leaseOwner: 'daemon-1',
leaseExpiresAt: new Date(Date.now() + 60_000),
updatedAt: new Date(),
})),
},
nationTurn: {
findMany: vi.fn(async () => []),
deleteMany,
createMany,
},
} as unknown as DatabaseClient;
await expect(setNationTurnAtCurrentPosition(db, 6, 12, 2, 'che_증축', {}, 4)).rejects.toMatchObject({
expectedRevision: 4,
currentRevision: 4,
});
expect(deleteMany).not.toHaveBeenCalled();
expect(createMany).not.toHaveBeenCalled();
});
});
+16 -1
View File
@@ -1021,7 +1021,22 @@ describe('appRouter', () => {
expect(response.turns[0]?.args).toEqual({ isGold: true, amount: 1, destGeneralId: 7 });
expect(response.turns[2]?.args).toEqual({ isGold: false, amount: 2, destGeneralId: 8 });
expect(nationWrites).toHaveLength(1);
const rebased = await caller.turns.reserved.setNationBulk({
generalId: general.id,
entries: [
{
turnList: [2],
action: 'che_포상',
args: { isGold: true, amount: 3, destGeneralId: 9 },
},
],
// 첫 요청 뒤 턴이 진행한 화면의 stale revision을 그대로 보낸 상황입니다.
expectedRevision: 0,
});
expect(rebased.revision).toBe(2);
expect(rebased.turns[2]?.args).toEqual({ isGold: true, amount: 3, destGeneralId: 9 });
expect(nationWrites).toHaveLength(2);
});
it('enforces only legacy reservation permissions without applying full execution constraints', async () => {
@@ -204,6 +204,20 @@ describe('tournament router permissions and mutations', () => {
expect(transport.gold.get(general.id)).toBe(1_800);
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0);
const snapshot = await caller.tournament.getSnapshot();
expect(snapshot.participants).toHaveLength(1);
expect(snapshot.participants[0]).toMatchObject({
id: general.id,
groupId: expect.any(Number),
groupNo: 0,
win: 0,
draw: 0,
lose: 0,
gl: 0,
});
expect(snapshot.participants[0]!.groupId).toBeGreaterThanOrEqual(0);
expect(snapshot.participants[0]!.groupId).toBeLessThan(8);
});
it('serializes concurrent bets and enforces the legacy per-user 1000 limit', async () => {
+53 -1
View File
@@ -12,7 +12,12 @@ import type {
TournamentState,
} from '../src/tournament/types.js';
import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js';
import { buildBettingPayouts, resolveBettingCloseAt, resolveNextAt } from '../src/tournament/workerHelpers.js';
import {
assignManualApplicantGroup,
buildBettingPayouts,
resolveBettingCloseAt,
resolveNextAt,
} from '../src/tournament/workerHelpers.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
class MemoryRedis {
@@ -226,6 +231,45 @@ const runTournamentToCompletion = async (options: {
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
describe('tournament worker schedule compatibility', () => {
it('수동 참가자를 즉시 남은 예선 조의 다음 슬롯에 배치한다', () => {
const current = Array.from({ length: 63 }, (_, index): TournamentParticipantEntry => {
const groupId = index < 47 ? index % 8 : (index + 1) % 8;
const groupNo = Math.floor(index / 8);
return {
id: index + 1,
name: `참가자${index + 1}`,
leadership: 70,
strength: 70,
intel: 70,
level: 10,
groupId,
groupNo,
};
});
const groupCounts = Array.from({ length: 8 }, (_, groupId) =>
current.filter((entry) => entry.groupId === groupId).length
);
const openGroupId = groupCounts.findIndex((count) => count === 7);
expect(openGroupId).toBeGreaterThanOrEqual(0);
expect(groupCounts.filter((count) => count === 7)).toHaveLength(1);
const applicant = assignManualApplicantGroup({
state: createTournamentState(),
baseSeed: 'manual-join-seed',
current,
applicant: {
id: 100,
name: '즉시배치',
leadership: 80,
strength: 81,
intel: 82,
level: 20,
},
});
expect(applicant).toMatchObject({ groupId: openGroupId, groupNo: 7, win: 0, draw: 0, lose: 0, gl: 0 });
});
it('catches up from the stored schedule instead of discarding elapsed legacy phases', () => {
const state = createTournamentState({
termSeconds: 600,
@@ -600,6 +644,14 @@ describe('tournament worker (in-memory)', () => {
expect(participants.some((entry) => entry.id === 99)).toBe(false);
expect(participants.some((entry) => entry.id === 1001)).toBe(true);
expect(participants.some((entry) => entry.id < 0)).toBe(true);
expect(participants.every((entry) => entry.groupId !== undefined && entry.groupNo !== undefined)).toBe(true);
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(
Array.from({ length: 8 }, (_, groupId) =>
participants.filter((entry) => entry.groupId === groupId).length
)
).toEqual(Array.from({ length: 8 }, () => 8));
await store.setState(afterJoin);
const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' });
+109 -5
View File
@@ -53,6 +53,18 @@ const inputOptions = {
{ value: 1, label: '장수 (아국 · 업)' },
{ value: 2, label: '관우 (아국 · 업)' },
],
generalTargets: {
che_포상: [
{ value: 1, label: '장수 (아국 · 업)' },
{ value: 2, label: '관우 (아국 · 업)' },
{ value: 3, label: '여포NPC (아국 · 업)' },
],
che_몰수: [
{ value: 1, label: '장수 (아국 · 업)' },
{ value: 2, label: '관우 (아국 · 업)' },
{ value: 3, label: '여포NPC (아국 · 업)' },
],
},
crewTypes: [{ value: 1100, label: '보병' }],
armTypes: [{ value: 1, label: '보병' }],
nationTypes: [{ value: 'che_도적', label: '도적', description: '금 수입 증가, 쌀 수입 감소' }],
@@ -389,8 +401,8 @@ const chiefCenter = {
maxTurns: 12,
chiefs: [12, 10, 8, 6, 11, 9, 7, 5].map((officerLevel) => ({
officerLevel,
name: officerLevel === 5 ? '장수' : null,
npcState: officerLevel === 5 ? 0 : null,
name: officerLevel === 5 ? '장수' : `수뇌${officerLevel}`,
npcState: officerLevel === 8 ? 2 : 0,
turnTime: null,
revision: 0,
turns: turns(12),
@@ -801,6 +813,92 @@ test('shows every Ref chief command in the exact category and command order', as
await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') });
});
test('shows all 12 advanced chief turns before the actions and uses the full mobile chief matrix', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 500, height: 900 });
await page.goto('/che/chief-center');
const editor = page.locator('[data-command-scope="nation"]:visible');
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
await expect(editor.locator('.index-column > button')).toHaveCount(12);
await expect(editor.locator('.index-column > button').last()).toHaveText('12');
await expect(editor.locator('.advanced-actions')).toContainText('선택한 턴을');
await expect(editor.locator('.advanced-actions')).toContainText('명령 선택');
const frame = page.locator('.chief-overview-frame');
await expect(frame.locator('.chief-overview-row')).toHaveCount(2);
await expect(frame.locator('.overview-turn-index')).toHaveCount(4);
for (const gutter of await frame.locator('.overview-turn-index').all()) {
await expect(gutter.locator('span').filter({ hasText: /\d+/u })).toHaveText(
Array.from({ length: 12 }, (_, index) => String(index + 1))
);
}
await expect(frame.locator('.compact-name')).toHaveCount(8);
const geometry = await page.locator('.chief-page').evaluate((element) => {
const editorElement = element.querySelector<HTMLElement>('[data-command-scope="nation"]')!;
const queue = editorElement.querySelector<HTMLElement>('.queue-grid')!;
const lastTurn = editorElement.querySelectorAll<HTMLElement>('.action-column > div')[11]!;
const actions = editorElement.querySelector<HTMLElement>('.advanced-actions')!;
const overviewFrame = element.querySelector<HTMLElement>('.chief-overview-frame')!;
const firstOverviewRow = element.querySelector<HTMLElement>('.chief-overview-row')!;
const overviewRows = [...element.querySelectorAll<HTMLElement>('.chief-overview-row')];
const gutters = [...firstOverviewRow.querySelectorAll<HTMLElement>('.overview-turn-index')];
const cards = [...firstOverviewRow.querySelectorAll<HTMLElement>('.chief-card')];
const names = [...overviewFrame.querySelectorAll<HTMLElement>('.compact-name')];
const frameRect = overviewFrame.getBoundingClientRect();
const editorRect = editorElement.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
return {
editorBottom: editorRect.bottom,
editorHeight: editorRect.height,
queueBottom: queue.getBoundingClientRect().bottom,
lastTurnBottom: lastTurn.getBoundingClientRect().bottom,
actionsTop: actionsRect.top,
actionsBottom: actionsRect.bottom,
frameTop: frameRect.top,
frameWidth: frameRect.width,
rowWidth: firstOverviewRow.getBoundingClientRect().width,
rowEdges: overviewRows.map((item) => ({
top: item.getBoundingClientRect().top - frameRect.top,
bottom: item.getBoundingClientRect().bottom - frameRect.top,
})),
gutterWidths: gutters.map((item) => item.getBoundingClientRect().width),
gutterEdges: gutters.map((item) => ({
left: item.getBoundingClientRect().left - frameRect.left,
right: item.getBoundingClientRect().right - frameRect.left,
})),
cardWidths: cards.map((item) => item.getBoundingClientRect().width),
namesInsideFrame: names.every((item) => {
const rect = item.getBoundingClientRect();
return rect.top >= frameRect.top && rect.bottom <= frameRect.bottom && rect.height > 0;
}),
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
expect(geometry.lastTurnBottom).toBeLessThanOrEqual(geometry.actionsTop);
expect(geometry.queueBottom).toBeLessThanOrEqual(geometry.actionsTop);
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.editorBottom);
expect(geometry.frameTop).toBeGreaterThanOrEqual(geometry.editorBottom);
expect(geometry.editorHeight).toBeGreaterThanOrEqual(404);
expect(geometry.frameWidth).toBe(500);
expect(geometry.rowWidth).toBe(500);
expect(geometry.rowEdges).toEqual([
{ top: 0, bottom: 155 },
{ top: 155, bottom: 310 },
]);
expect(geometry.gutterWidths).toEqual([12, 12]);
expect(geometry.gutterEdges).toEqual([
{ left: 0, right: 12 },
{ left: 488, right: 500 },
]);
expect(geometry.cardWidths).toEqual([119, 119, 119, 119]);
expect(geometry.namesInsideFrame).toBe(true);
expect(geometry.documentOverflow).toBeLessThanOrEqual(0);
await page.screenshot({ path: test.info().outputPath('chief-advanced-mobile-500.png'), fullPage: true });
});
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
const requests = await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
@@ -891,7 +989,13 @@ test('enters general and nation command arguments and sends exact values', async
const chiefForm = chiefPicker.getByTestId('command-argument-form');
await chiefForm.getByRole('button', { name: '쌀' }).click();
await chiefForm.locator('input[type=number]').fill('300');
await chiefForm.locator('select').selectOption('2');
const chiefTarget = chiefForm.locator('select');
await expect(chiefTarget.locator('option')).toHaveText([
'장수 (아국 · 업)',
'관우 (아국 · 업)',
'여포NPC (아국 · 업)',
]);
await chiefTarget.selectOption('3');
const geometry = await chiefForm.evaluate((element) => {
const row = element.querySelector('.argument-row');
const rect = element.getBoundingClientRect();
@@ -905,13 +1009,13 @@ test('enters general and nation command arguments and sends exact values', async
});
await chiefPicker.getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
'【관우】 쌀 300 포상'
'【여포NPC】 쌀 300 포상'
);
expect(JSON.stringify(requests)).toContain('"destCityId":2');
expect(JSON.stringify(requests)).toContain('"isGold":false');
expect(JSON.stringify(requests)).toContain('"amount":300');
expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
expect(JSON.stringify(requests)).toContain('"destGeneralId":3');
expect(mapGeometry.width).toBeGreaterThan(650);
expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2);
+12
View File
@@ -875,6 +875,18 @@ test('current-city wraps dense general names and only shrinks reserved turns', a
expect(pageStyle.fontFamily).toContain('Pretendard');
expect(pageStyle.fontSize).toBe('14px');
const controlFonts = await page
.locator('.city-page')
.evaluate(() =>
['#citySelector', '.back-link'].map(
(selector) => getComputedStyle(document.querySelector(selector)!).fontFamily
)
);
expect(controlFonts).toHaveLength(2);
for (const fontFamily of controlFonts) {
expect(fontFamily).toContain('Pretendard');
}
const names = page.locator('.general-names');
await expect(names).toHaveCSS('white-space', 'normal');
const nameLineCount = await names.locator('span').evaluateAll((elements) => {
+91
View File
@@ -791,6 +791,97 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을
await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry);
});
test('메인 개인 기록의 공격·수비 시각은 Ref와 같은 90% 글자 크기로 표시한다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
myset: 3,
settingMutations: [],
accessPages: [],
recentRecords: {
global: [],
general: [
{
id: 18703,
text: '<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>수비</>합니다.',
createdAt: '2026-01-01T03:54:00.000Z',
},
{
id: 18702,
text: '<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>공격</>합니다.',
createdAt: '2026-01-01T03:55:00.000Z',
},
{
id: 18701,
text: '<C>●</>10월:이미 기록된 시각 <1>12:34</>',
createdAt: '2026-01-01T03:56:00.000Z',
},
],
history: [],
},
};
await install(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('');
const inspect = async (selector: string) => {
const lines = page.locator(selector);
await expect(lines).toHaveCount(3);
await expect(lines.nth(0)).toHaveText('●10월:천귀병으로 ⓝ염행의 보병을 수비합니다. 12:54');
await expect(lines.nth(1)).toHaveText('●10월:천귀병으로 ⓝ염행의 보병을 공격합니다. 12:55');
await expect(lines.nth(2)).toHaveText('●10월:이미 기록된 시각 12:34');
return lines.evaluateAll((elements) =>
elements.map((element) => {
const spans = [...element.querySelectorAll<HTMLElement>('span')];
const time = spans.find((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? ''));
const name = spans.find((span) => span.textContent === 'ⓝ염행');
const action = spans.find((span) => span.textContent === '수비' || span.textContent === '공격');
if (!time) throw new Error('개인 기록 시각 span을 찾지 못했습니다.');
const rect = element.getBoundingClientRect();
return {
text: element.textContent,
row: {
width: rect.width,
height: rect.height,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
fontSize: getComputedStyle(element).fontSize,
lineHeight: getComputedStyle(element).lineHeight,
},
time: {
fontSize: getComputedStyle(time).fontSize,
lineHeight: getComputedStyle(time).lineHeight,
},
nameFontSize: name ? getComputedStyle(name).fontSize : null,
actionFontSize: action ? getComputedStyle(action).fontSize : null,
timeSpanCount: spans.filter((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? '')).length,
};
})
);
};
const assertFontContract = (measurements: Awaited<ReturnType<typeof inspect>>) => {
expect(measurements.map((entry) => entry.row.fontSize)).toEqual(['14px', '14px', '14px']);
expect(measurements.map((entry) => entry.row.lineHeight)).toEqual(['21px', '21px', '21px']);
expect(measurements.map((entry) => entry.row.height)).toEqual([21, 21, 21]);
expect(measurements.map((entry) => entry.time.fontSize)).toEqual(['12.6px', '12.6px', '12.6px']);
expect(measurements.map((entry) => entry.timeSpanCount)).toEqual([1, 1, 1]);
expect(measurements[0]?.nameFontSize).toBe('14px');
expect(measurements[0]?.actionFontSize).toBe('14px');
expect(measurements[1]?.nameFontSize).toBe('14px');
expect(measurements[1]?.actionFontSize).toBe('14px');
expect(measurements.every((entry) => entry.row.scrollWidth <= entry.row.clientWidth)).toBe(true);
};
const desktop = await inspect('.record-zone [data-record-bucket="general"] .record-line');
assertFontContract(desktop);
await persistParityArtifact(page, 'core-main-personal-war-log-time-font-desktop', desktop);
await page.setViewportSize({ width: 500, height: 900 });
const mobile = await inspect('.record-zone-mobile [data-record-bucket="general"] .record-line');
assertFontContract(mobile);
await persistParityArtifact(page, 'core-main-personal-war-log-time-font-mobile', mobile);
});
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
await install(page, state);
@@ -118,7 +118,8 @@ const persistScreenshot = async (page: Page, name: string, fallbackPath: string)
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
};
const installFixture = async (page: Page) => {
const installFixture = async (page: Page, options: { applicationOpen?: boolean } = {}) => {
let joined = false;
await page.addInitScript((profile) => {
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
window.localStorage.setItem('sammo-game-profile', profile);
@@ -141,7 +142,7 @@ const installFixture = async (page: Page) => {
if (operation === 'tournament.getSnapshot') {
return response({
state: {
stage: 0,
stage: options.applicationOpen ? 1 : 0,
phase: 0,
type: 0,
auto: false,
@@ -151,11 +152,32 @@ const installFixture = async (page: Page) => {
nextAt: '2026-08-02T00:00:00.000Z',
winnerId: 1,
},
participants,
participants:
options.applicationOpen && !joined
? []
: options.applicationOpen
? [
{
...participants[0],
groupId: 0,
groupNo: 0,
win: 0,
draw: 0,
lose: 0,
gl: 0,
seedRank: 0,
finalRank: 0,
},
]
: participants,
matches,
betCount: 16,
});
}
if (operation === 'tournament.join') {
joined = true;
return response({ ok: true, count: 1 });
}
if (operation === 'tournament.getBettingSummary') {
return response({
totals: Object.fromEntries(
@@ -245,9 +267,67 @@ test('desktop bracket connects every real general slot to the next round', async
expect(geometry.horizontalIdentities).toBe(true);
expect(Math.abs(geometry.firstParentY - geometry.firstPairAverageY)).toBeLessThan(1);
const controls = await page.locator('#tournament-container').evaluate((container) => {
const bounds = (selector: string) => container.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
const refresh = bounds('.toolbar button:first-child');
const join = bounds('.join-button');
const close = bounds('.close-button');
return {
refresh: { width: refresh.width, height: refresh.height },
join: { width: join.width, height: join.height },
close: { width: close.width, height: close.height },
};
});
expect(controls.refresh).toEqual({ width: 72, height: 44 });
expect(controls.join).toEqual({ width: 72, height: 44 });
expect(controls.close).toEqual({ width: 88, height: 44 });
const firstSlot = page.locator('.desktop-bracket-name').first();
const oddsContainment = await firstSlot.evaluate((slot) => {
const card = slot.getBoundingClientRect();
const odds = slot.querySelector<HTMLElement>('.bracket-odds')!.getBoundingClientRect();
return {
cardTop: card.top,
cardBottom: card.bottom,
oddsTop: odds.top,
oddsBottom: odds.bottom,
cardHeight: card.height,
};
});
expect(oddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
expect(oddsContainment.oddsTop).toBeGreaterThanOrEqual(oddsContainment.cardTop);
expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom);
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
});
test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page, { applicationOpen: true });
await page.goto('tournament');
const refresh = page.getByRole('button', { name: '갱신' });
const join = page.getByRole('button', { name: '참가' });
const close = page.getByRole('button', { name: '창 닫기' }).first();
await expect(join).toBeEnabled();
await join.click();
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.');
await expect(join).toBeDisabled();
await expect(page.locator('.preliminary-grid .general-identity', { hasText: names[0] })).toBeVisible();
for (const control of [refresh, join, close]) {
const box = await control.boundingBox();
expect(box?.height).toBe(44);
expect(box?.width).toBeGreaterThanOrEqual(72);
}
await refresh.focus();
await expect(refresh).toBeFocused();
await refresh.hover();
await expect(refresh).toHaveCSS('filter', 'brightness(1.25)');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
});
test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({
page,
}, testInfo) => {
@@ -325,6 +405,14 @@ test('mobile bracket exposes every round through tabs with standard horizontal i
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight - 1);
expect(identity.nameTop).toBeLessThan(identity.iconBottom);
expect(identity.nameBottom).toBeGreaterThan(identity.iconTop);
const firstMobileSlot = bracket.locator('.mobile-bracket-name').first();
const mobileOddsContainment = await firstMobileSlot.evaluate((slot) => {
const card = slot.getBoundingClientRect();
const odds = slot.querySelector<HTMLElement>('.bracket-odds')!.getBoundingClientRect();
return { cardBottom: card.bottom, oddsBottom: odds.bottom, cardHeight: card.height };
});
expect(mobileOddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
expect(mobileOddsContainment.oddsBottom).toBeLessThanOrEqual(mobileOddsContainment.cardBottom);
await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible();
await page.getByRole('tab', { name: '二조' }).first().click();
await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true');
+1
View File
@@ -1,3 +1,4 @@
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
@import 'tailwindcss';
@import './styles/tokens.css';
@import './styles/legacy-controls.css';
@@ -685,7 +685,9 @@ const clickOutsideMenu = (event: Event) => {
class="command-picker"
:class="{ 'recruitment-picker': isRecruitmentCommand }"
data-testid="command-picker"
:style="isRecruitmentCommand || props.compact ? undefined : { top: quickPickerTop }"
:style="
isRecruitmentCommand || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }
"
:role="isRecruitmentCommand ? 'dialog' : undefined"
:aria-modal="isRecruitmentCommand ? 'true' : undefined"
:aria-label="
@@ -1146,12 +1148,15 @@ const clickOutsideMenu = (event: Event) => {
}
.mobile.compact .editor-layout {
height: 360px;
min-height: 370px;
display: grid;
grid-template-columns: 109px 391px;
grid-template-rows: auto auto;
}
.mobile.compact .control-pad {
order: initial;
grid-column: 1;
grid-row: 1 / -1;
min-height: 0;
padding: 0;
grid-template-columns: 1fr;
@@ -1159,6 +1164,8 @@ const clickOutsideMenu = (event: Event) => {
}
.mobile.compact .queue-area {
order: initial;
grid-column: 2;
grid-row: 1;
padding-top: 10px;
}
.mobile.compact .queue-grid {
@@ -1189,8 +1196,8 @@ const clickOutsideMenu = (event: Event) => {
overflow: visible;
}
.mobile.compact .advanced-actions {
right: 0;
bottom: 0;
left: 109px;
position: static;
grid-column: 2;
grid-row: 2;
}
</style>
@@ -75,7 +75,10 @@ export const formatReservedCommandBrief = (
const commandName = commandNames.get(action) ?? defaultCommandName(action);
const cityName = optionLabel(input?.cities ?? [], firstValue(args, ['destCityId', 'destCityID']));
const nationName = optionLabel(input?.nations ?? [], firstValue(args, ['destNationId', 'destNationID']));
const generalName = optionLabel(input?.generals ?? [], firstValue(args, ['destGeneralId', 'destGeneralID']));
const generalName = optionLabel(
input?.generalTargets?.[action] ?? input?.generals ?? [],
firstValue(args, ['destGeneralId', 'destGeneralID'])
);
if (CITY_TO_COMMANDS.has(action) && cityName) {
return `${wrappedWithParticle(cityName, '으로')} ${commandName}`;
@@ -95,6 +95,7 @@ export type CommandTable = {
cities: CommandOption[];
nations: CommandOption[];
generals: CommandOption[];
generalTargets?: Record<string, CommandOption[]>;
crewTypes: CommandOption[];
armTypes: CommandOption[];
nationTypes: CommandOption[];
@@ -33,6 +33,9 @@ const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !
const optionsFor = (field: CommandInputField): CommandOption[] => {
if (field.options) return field.options;
if (!field.optionSource) return [];
if (field.optionSource === 'generals') {
return props.options.generalTargets?.[props.commandKey] ?? props.options.generals;
}
if (field.optionSource === 'items') {
return props.options.items[String(values.itemType ?? '')] ?? [];
}
@@ -41,7 +41,7 @@ const formattedLogs = computed(() =>
.recent-log-list {
min-width: 0;
color: #fff;
font-family: 'Times New Roman', serif;
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1.35;
}
@@ -28,8 +28,10 @@ const roundColumns = computed(() => [
]);
const desktopX = [110, 355, 600, 845, 1090];
const cardWidth = 190;
const desktopSlotHeight = 88;
const desktopCanvasHeight = desktopSlotHeight * 16;
const slotY = (columnIndex: number, slotIndex: number) => {
const slotHeight = 72 * 2 ** columnIndex;
const slotHeight = desktopSlotHeight * 2 ** columnIndex;
return slotHeight / 2 + slotIndex * slotHeight;
};
const connections = computed(() =>
@@ -77,8 +79,8 @@ const mobilePairs = computed(() => {
<div class="desktop-round-labels" aria-hidden="true">
<strong v-for="label in roundLabels" :key="label">{{ label }}</strong>
</div>
<div class="desktop-bracket-canvas">
<svg viewBox="0 0 1200 1152" aria-hidden="true">
<div class="desktop-bracket-canvas" :style="{ height: `${desktopCanvasHeight}px` }">
<svg :viewBox="`0 0 1200 ${desktopCanvasHeight}`" aria-hidden="true">
<g v-for="connection in connections" :key="connection.id">
<path
class="bracket-connector"
@@ -177,7 +179,6 @@ const mobilePairs = computed(() => {
.desktop-bracket-canvas {
position: relative;
width: 100%;
height: 1152px;
}
.desktop-bracket-canvas svg {
position: absolute;
@@ -200,7 +201,7 @@ const mobilePairs = computed(() => {
display: grid;
box-sizing: border-box;
width: clamp(140px, 16vw, 190px);
min-height: 68px;
min-height: 82px;
align-items: center;
overflow: hidden;
transform: translate(-50%, -50%);
@@ -265,7 +266,7 @@ const mobilePairs = computed(() => {
.mobile-bracket-name {
box-sizing: border-box;
min-width: 0;
min-height: 68px;
min-height: 82px;
overflow: hidden;
border: 1px solid #555;
background: rgb(58 33 24 / 94%);
+11 -5
View File
@@ -304,16 +304,16 @@ const placeBet = async (targetId: number) => {
background: #142b42 var(--sammo-texture-blue);
}
.title {
height: 55.6875px;
min-height: 68px;
padding: 0;
font-size: 14px;
line-height: 19.1875px;
}
.close-button {
display: block;
width: 62px;
height: 35.5px;
padding: 8px 12px;
width: 88px;
height: 44px;
padding: 10px 16px;
border: 1px solid #375a7f;
border-radius: 5.25px;
background: #375a7f;
@@ -323,10 +323,16 @@ const placeBet = async (targetId: number) => {
text-decoration: none;
}
.toolbar {
min-height: 36.5px;
min-height: 46px;
padding: 1px;
text-align: left;
}
.toolbar button {
min-width: 72px;
height: 44px;
padding: 10px 16px;
font-size: 14px;
}
.error {
min-height: 32px;
padding: 5px;
+1 -3
View File
@@ -252,9 +252,7 @@ onMounted(() => {
padding: 8px;
color: #000;
background: #fff;
font:
16px/normal 'Times New Roman',
serif;
font: 16px/normal var(--sammo-font-sans);
}
.legacy-board-page {
+63 -25
View File
@@ -352,22 +352,34 @@ const repeatTurns = async (amount: number) => {
</div>
<div class="chief-overview-frame">
<div class="chief-overview">
<template v-for="chief in chiefViews" :key="chief.officerLevel">
<ChiefTurnCard
v-if="chief.name"
:officer-level-text="chief.officerLevelText"
:name="chief.name"
:npc-state="chief.npcState"
:rows="chief.rows"
:compact="true"
:selected="chief.officerLevel === selectedChief?.officerLevel"
:is-me="chief.officerLevel === data.me.officerLevel"
:clickable="true"
:turn-time-label="chief.rows[0]?.time"
@select="selectedChiefLevel = chief.officerLevel"
/>
<div v-else class="empty-chief-slot" aria-hidden="true"></div>
</template>
<div
v-for="(rowChiefs, rowIndex) in [chiefViews.slice(0, 4), chiefViews.slice(4, 8)]"
:key="rowIndex"
class="chief-overview-row"
>
<div class="overview-turn-index legacy-bg0" :aria-label="`${rowIndex + 1} 번호`">
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
</div>
<template v-for="chief in rowChiefs" :key="chief.officerLevel">
<ChiefTurnCard
v-if="chief.name"
:officer-level-text="chief.officerLevelText"
:name="chief.name"
:npc-state="chief.npcState"
:rows="chief.rows"
:compact="true"
:selected="chief.officerLevel === selectedChief?.officerLevel"
:is-me="chief.officerLevel === data.me.officerLevel"
:clickable="true"
:turn-time-label="chief.rows[0]?.time"
@select="selectedChiefLevel = chief.officerLevel"
/>
<div v-else class="empty-chief-slot" aria-hidden="true"></div>
</template>
<div class="overview-turn-index legacy-bg0" :aria-label="`${rowIndex + 1} 번호`">
<span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
</div>
</div>
</div>
</div>
</section>
@@ -750,20 +762,25 @@ const repeatTurns = async (amount: number) => {
.chief-overview-frame {
width: 500px;
height: 310px;
margin-top: -3px;
margin-top: 0;
margin-bottom: 11px;
overflow: hidden;
}
.chief-overview {
width: 445px;
width: 500px;
height: 310px;
margin-top: 0;
display: flex;
flex-direction: column;
}
.chief-overview-row {
width: 500px;
height: 155px;
display: grid;
grid-template-columns: repeat(4, 111.25px);
grid-auto-rows: 155px;
grid-template-columns: 12px repeat(4, 119px) 12px;
}
.chief-overview :deep(.chief-card) {
width: 111.25px;
width: 119px;
height: 155px;
border: 0;
border-left: 1px solid #fff;
@@ -787,13 +804,34 @@ const repeatTurns = async (amount: number) => {
box-sizing: border-box;
height: 20px !important;
min-height: 20px !important;
grid-template-rows: none;
grid-template-columns: 1fr;
grid-template-rows: 10px 10px;
line-height: 10px;
}
.chief-overview :deep(.compact-name),
.chief-overview :deep(.compact-meta) {
height: 10px;
line-height: 10px;
}
.chief-overview :deep(.row-time),
.chief-overview :deep(.row-action) {
display: grid;
place-items: center;
}
.overview-turn-index {
display: grid;
grid-template-rows: 20px repeat(12, 11.25px);
width: 12px;
height: 155px;
color: #fff;
font-size: 0.55rem;
line-height: 11.25px;
text-align: center;
}
.overview-turn-index span {
display: grid;
place-items: center;
}
.mobile-readonly {
width: 404px;
height: 420px;
@@ -879,7 +917,7 @@ const repeatTurns = async (amount: number) => {
@media (max-width: 1024px) {
.chief-overview {
grid-template-columns: repeat(4, 111.25px);
width: 500px;
}
}
@@ -891,8 +929,8 @@ const repeatTurns = async (amount: number) => {
.chief-grid-row > .empty-chief-slot {
height: 384px;
}
.chief-overview > .empty-chief-slot {
width: 111.25px;
.chief-overview-row > .empty-chief-slot {
width: 119px;
height: 155px;
}
</style>
@@ -366,7 +366,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
border: 1px solid #767676;
background: #6b6b6b;
color: #fff;
font-family: Arial, sans-serif;
font-family: var(--sammo-font-sans);
font-size: 13.3333px;
}
.selector {
@@ -470,7 +470,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
background: #6c757d;
color: #fff;
padding: 0.25rem 0.5rem;
font-family: Arial, sans-serif;
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1;
text-decoration: none;
@@ -305,7 +305,7 @@ onMounted(loadDetail);
width: 1000px;
margin: 8px auto 0;
color: #fff;
font-family: 'Times New Roman', serif;
font-family: var(--sammo-font-sans);
font-size: 16px;
line-height: normal;
}
@@ -384,7 +384,7 @@ onMounted(loadDetail);
padding: 1px 6px;
background: buttonface;
color: buttontext;
font-family: Arial;
font-family: var(--sammo-font-sans);
font-size: 13.3333px;
font-weight: 400;
line-height: normal;
@@ -231,7 +231,7 @@ onMounted(loadDynasty);
padding: 1px 6px;
background: buttonface;
color: buttontext;
font-family: Arial;
font-family: var(--sammo-font-sans);
font-size: 13.3333px;
font-weight: 400;
line-height: normal;
+1 -6
View File
@@ -778,12 +778,7 @@ onMounted(() => {
padding: 0 7px;
color: #fff;
height: 1597px;
font:
14px/21px Pretendard,
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
sans-serif;
font: 14px/21px var(--sammo-font-sans);
}
.inherit-page.legacy-bg0 {
+3 -2
View File
@@ -66,11 +66,12 @@ const nationAccess = computed(() => ({
}));
const nationColor = computed(() => nation.value?.color ?? '#000000');
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
const recordTimeSuffixPattern = /\d{2}:\d{2}(?:<\/>)?\s*$/u;
const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => {
if (!appendTime || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text);
if (!appendTime || recordTimeSuffixPattern.test(entry.text)) return formatLog(entry.text);
const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' });
if (!time) return formatLog(entry.text);
return formatLog(`${entry.text} ${time}`);
return formatLog(`${entry.text} <1>${time}</>`);
};
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
@@ -451,7 +451,7 @@ onMounted(() => {
width: 500px;
margin: 0 auto;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1.5;
}
@@ -522,12 +522,7 @@ onMounted(() => void loadPersonnel());
min-height: 100vh;
margin: 0 auto;
color: #fff;
font:
14px/1.3 Pretendard,
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
sans-serif;
font: 14px/1.3 var(--sammo-font-sans);
}
.legacy-table {
width: 1000px;
@@ -174,12 +174,7 @@ onMounted(load);
.secret-page {
width: auto;
margin: 0;
font:
14px Pretendard,
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
sans-serif;
font: 14px var(--sammo-font-sans);
color: #fff;
}
.layout {
@@ -437,12 +437,7 @@ onMounted(() => void loadStratFinan());
margin: 0 auto;
color: #fff;
background: var(--sammo-texture-walnut);
font:
14px/1.3 Pretendard,
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
sans-serif;
font: 14px/1.3 var(--sammo-font-sans);
}
.tiptap-compat-controls {
display: none;
+1 -1
View File
@@ -165,7 +165,7 @@ onMounted(() => {
min-height: 100vh;
margin: 0 auto;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1.3;
}
@@ -515,8 +515,6 @@ onBeforeUnmount(() => {
</template>
<style scoped>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
.select-pool-page {
width: 1000px;
min-width: 1000px;
+1 -1
View File
@@ -423,7 +423,7 @@ onMounted(() => {
.pageVote {
margin: 0 auto;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1.5;
}
+11 -5
View File
@@ -385,16 +385,16 @@ const start = async () => {
background: #142b42 var(--sammo-texture-blue);
}
.legacy-title {
height: 55.6875px;
min-height: 68px;
padding: 0;
font-size: 14px;
line-height: 19.1875px;
}
.close-button {
display: block;
width: 62px;
height: 35.5px;
padding: 8px 12px;
width: 88px;
height: 44px;
padding: 10px 16px;
border: 1px solid #375a7f;
border-radius: 5.25px;
background: #375a7f;
@@ -404,9 +404,15 @@ const start = async () => {
text-decoration: none;
}
.toolbar {
min-height: 36.5px;
min-height: 46px;
padding: 1px;
}
.toolbar button {
min-width: 72px;
height: 44px;
padding: 10px 16px;
font-size: 14px;
}
.operator-row,
.state-row,
.error-row,
+1 -1
View File
@@ -361,7 +361,7 @@ onMounted(() => {
margin: 0 auto;
color: #fff;
background: transparent;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1.5;
}
+3 -3
View File
@@ -378,10 +378,10 @@ onMounted(async () => {
grid-column: 1 / -1;
}
.history-log:first-of-type {
height: 128px;
min-height: 128px;
}
.history-log:last-of-type {
height: 65px;
min-height: 65px;
}
.dropdown-compat-buttons {
display: none;
@@ -441,7 +441,7 @@ onMounted(async () => {
order: 4;
}
.history-log:first-of-type {
height: 149px;
min-height: 149px;
margin-bottom: 0;
}
}
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import { describe, it } from 'node:test';
const sourceRoot = path.resolve(import.meta.dirname, '../src');
const fontFamilyDeclaration = /font-family\s*:\s*([^;]+);/g;
const fontShorthandDeclaration = /(?:^|[\s{])font\s*:\s*([^;]+);/gm;
const listStyleSources = async (): Promise<string[]> => {
const entries = await readdir(sourceRoot, { recursive: true, withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && (entry.name.endsWith('.css') || entry.name.endsWith('.vue')))
.map((entry) => path.join(entry.parentPath, entry.name))
.sort();
};
void describe('game content font contract', () => {
void it('loads Pretendard once from the global stylesheet entry', async () => {
const importPattern =
/@import url\(['"]https:\/\/cdn\.jsdelivr\.net\/gh\/orioncactus\/pretendard\/dist\/web\/static\/pretendard\.css['"]\);/g;
const imports: string[] = [];
for (const file of await listStyleSources()) {
const source = await readFile(file, 'utf8');
if (importPattern.test(source)) {
imports.push(path.relative(sourceRoot, file));
}
importPattern.lastIndex = 0;
}
assert.deepEqual(imports, ['assets/main.css']);
});
void it('uses the shared sans token for every explicit content font declaration', async () => {
const violations: string[] = [];
for (const file of await listStyleSources()) {
const source = await readFile(file, 'utf8');
for (const match of source.matchAll(fontFamilyDeclaration)) {
const value = match[1]?.trim();
if (value !== 'inherit' && value !== 'var(--sammo-font-sans)') {
violations.push(`${path.relative(sourceRoot, file)}: font-family: ${value}`);
}
}
for (const match of source.matchAll(fontShorthandDeclaration)) {
const value = match[1]?.trim();
if (value !== 'inherit' && !value?.includes('var(--sammo-font-sans)')) {
violations.push(`${path.relative(sourceRoot, file)}: font: ${value}`);
}
}
}
assert.deepEqual(violations, []);
});
});
@@ -81,6 +81,12 @@ const table: CommandTable = {
{ value: 8, label: '손권 (오 · 단양)' },
{ value: 9, label: '조조 (위 · 업)' },
],
generalTargets: {
che_포상: [
{ value: 8, label: '손권 (오 · 단양)' },
{ value: 10, label: '여포NPC (오 · 단양)' },
],
},
crewTypes: [{ value: 1100, label: '보병' }],
armTypes: [
{ value: 0, label: '보병' },
@@ -165,6 +171,10 @@ test('국가 명령의 도시·국가·장수·자원 인자를 Ref brief로 표
for (const [action, args, expected] of cases) {
assert.equal(formatReservedCommandBrief('nation', action, args, table), expected, action);
}
assert.equal(
formatReservedCommandBrief('nation', 'che_포상', { destGeneralId: 10, amount: 300, isGold: false }, table),
'【여포NPC】 쌀 300 포상'
);
});
test('Ref가 getBrief를 재정의하지 않은 명령은 실제 표시명을 유지한다', () => {
+15
View File
@@ -122,6 +122,21 @@ Adding or changing a frontend route requires:
Pixel snapshots may be added after these structural assertions pass. Dynamic
regions must not be hidden merely to make a pixel threshold pass.
개인 공격·수비 기록의 Ref 글자 크기는 checkout의 실제 `formatLog.ts`와 빌드된
`v_main.css`를 사용하는 정적 Chromium fixture로 독립 재현할 수 있습니다. 이
helper는 고정된 비민감 기록 문구만 렌더링하므로 live PHP session이나 DB 저장
경로 검증을 대신하지 않습니다.
```sh
REF_SAM_ROOT=/path/to/ref/sam \
REF_PERSONAL_WAR_LOG_ARTIFACT_DIR=/path/to/ignored/artifacts \
node tools/frontend-legacy-parity/reference-personal-war-log-font.mjs
```
대응하는 Core 검증은 `inGameMenus.spec.ts`의 “공격·수비 시각” test이며,
`MENU_PARITY_ARTIFACT_DIR`를 지정하면 1200×900·500×900 screenshot과 computed
style JSON을 남깁니다.
To refresh the PHP ranking evidence after building the ignored reference
webpack assets, run:
@@ -221,19 +221,19 @@ test('dynasty list matches the ref Chromium table geometry and interactions', as
{ y: 65, height: 37 },
{ y: 112, height: 139 },
]);
expect(geometry.tables[0]!.fontFamily).toContain('Times New Roman');
expect(geometry.tables[0]!.fontSize).toBe('16px');
expect(geometry.tables[0]!.lineHeight).toBe('normal');
expect(geometry.button).toEqual({
expect(geometry.tables[0]!.fontFamily).toContain('Pretendard');
expect(geometry.tables[0]!.fontSize).toBe('14px');
expect(geometry.tables[0]!.lineHeight).toBe('18.2px');
expect(geometry.button.fontFamily).toContain('Pretendard');
expect(geometry.button).toMatchObject({
height: 22,
borderWidth: '2px',
borderRadius: '0px',
padding: '1px 6px',
fontFamily: 'Arial',
fontSize: '13.3333px',
cursor: 'default',
});
expect(geometry.firstCell).toEqual({ padding: '1px', borderWidth: '0px', textAlign: 'start' });
expect(geometry.firstCell).toEqual({ padding: '0px', borderWidth: '1px', textAlign: 'start' });
const historyLink = page.getByRole('link', { name: '역사 보기' }).last();
await expect(historyLink).toHaveAttribute('href', '/che/yearbook?serverID=hwe_260725_u3uE');
@@ -276,17 +276,23 @@ test('dynasty detail preserves the legacy fields, old-nation table and error flo
const first = container.querySelector<HTMLTableElement>('table')!.getBoundingClientRect();
const emperor = container.querySelector<HTMLTableElement>('.emperor-table')!.getBoundingClientRect();
const oldNation = container.querySelector<HTMLTableElement>('.old-nation-table')!.getBoundingClientRect();
const containerStyle = getComputedStyle(container);
const buttonStyle = getComputedStyle(container.querySelector<HTMLButtonElement>('button')!);
return {
container: { x: rect.x, y: rect.y, width: rect.width },
first: { x: first.x, y: first.y, width: first.width, height: first.height },
emperor: { x: emperor.x, y: emperor.y, width: emperor.width },
oldNation: { x: oldNation.x, width: oldNation.width },
fontFamily: containerStyle.fontFamily,
buttonFontFamily: buttonStyle.fontFamily,
};
});
expect(geometry.container).toEqual({ x: 140, y: 8, width: 1000 });
expect(geometry.first).toEqual({ x: 140, y: 8, width: 1000, height: 47 });
expect(geometry.emperor).toEqual({ x: 140, y: 55, width: 1000 });
expect(geometry.oldNation).toEqual({ x: 140, width: 1000 });
expect(geometry.fontFamily).toContain('Pretendard');
expect(geometry.buttonFontFamily).toContain('Pretendard');
await page.goto(`${gameOrigin}/che/dynasty/999`);
await expect(page.getByRole('alert')).toHaveText('왕조 정보를 찾을 수 없습니다.');
@@ -0,0 +1,40 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig, devices } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15126';
const gameOrigin = `http://127.0.0.1:${gamePort}`;
const frontendEnv =
'VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc ' +
'VITE_IMAGE_PUBLIC_URL=/image VITE_GAME_ASSET_URL=/image ' +
'VITE_GAME_PROFILE=che VITE_GATEWAY_WEB_URL=/gateway/';
export default defineConfig({
testDir: '.',
testMatch: ['dynasty-parity.spec.ts', 'map-trend.spec.ts'],
fullyParallel: false,
workers: 1,
timeout: 30_000,
expect: { timeout: 5_000 },
reporter: [['list']],
outputDir: process.env.SAMMO_TEST_OUTPUT_DIR ?? resolve(repositoryRoot, 'test-results/game-font'),
use: {
...devices['Desktop Chrome'],
baseURL: `${gameOrigin}/che/`,
colorScheme: 'dark',
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'UTC',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: {
command: `${frontendEnv} pnpm --filter @sammo-ts/game-frontend build && ${frontendEnv} pnpm --filter @sammo-ts/game-frontend preview --host 127.0.0.1 --port ${gamePort}`,
cwd: repositoryRoot,
url: `${gameOrigin}/che/`,
reuseExistingServer: false,
timeout: 120_000,
},
});
@@ -179,6 +179,15 @@ const installMainFixture = async (page: Page, failRecords = false) => {
myNation: 1,
});
}
if (operation === 'public.getMapLayout') return response(fixture.game.mapLayout);
if (operation === 'public.getCachedMap') {
return response({ ...fixture.game.map, history: cachedHistory });
}
if (operation === 'public.getWorldTrend') {
return response({ year: 200, month: 1, turnTerm: 10 });
}
if (operation === 'public.getNationList') return response([]);
if (operation === 'public.getGeneralList') return response([]);
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'turns.reserved.getGeneral') return response([]);
if (operation === 'messages.getRecent') return response(emptyMessages);
@@ -265,6 +274,40 @@ test('shows the current in-game map and all three recent record streams', async
}
});
test('uses the shared content font for public game history on desktop and mobile', async ({ page }) => {
await installMainFixture(page);
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
await page.goto(gameUrl('/public'));
await expect(page.locator('.recent-log-list')).toContainText('유비가 촉을 건국하였습니다.');
const font = await page.locator('.recent-log-list').evaluate((element) => {
const style = getComputedStyle(element);
return { family: style.fontFamily, size: style.fontSize };
});
expect(font.family).toContain('Pretendard');
expect(font.size).toBe('14px');
const pretendardFont = await page.evaluate(async () => {
await document.fonts.load('400 14px Pretendard', '유비가 촉을 건국하였습니다.');
await document.fonts.ready;
const statuses = [...document.fonts]
.filter((face) => face.family.replaceAll('"', '') === 'Pretendard')
.map((face) => face.status);
return {
statuses,
koreanGlyphsLoaded: document.fonts.check('400 14px Pretendard', '유비가 촉을 건국하였습니다.'),
};
});
expect(pretendardFont.statuses).toContain('loaded');
expect(pretendardFont.koreanGlyphsLoaded).toBe(true);
}
});
test('keeps the current map visible when the recent record request fails', async ({ page }) => {
await installMainFixture(page, true);
await page.setViewportSize({ width: 1440, height: 1000 });
@@ -0,0 +1,83 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { chromium } from '@playwright/test';
const refRoot = resolve(process.env.REF_SAM_ROOT ?? '/home/letrhee/sam_rebuild/ref/sam');
const artifactDir = process.env.REF_PERSONAL_WAR_LOG_ARTIFACT_DIR;
const formatterUrl = pathToFileURL(resolve(refRoot, 'hwe/ts/utilGame/formatLog.ts')).href;
const { formatLog } = await import(formatterUrl);
const css = await readFile(resolve(refRoot, 'dist_js/hwe_dynamic/vue/v_main.css'), 'utf8');
const records = [
'<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>수비</>합니다. <1>12:54</>',
'<C>●</>10월:천귀병으로 <Y>ⓝ염행</>의 보병을 <M>공격</>합니다. <1>12:55</>',
];
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
colorScheme: 'dark',
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'UTC',
});
const page = await context.newPage();
const lines = records.map((record) => `<div class="fixture-line">${formatLog(record)}</div>`).join('');
await page.setContent(
`<!doctype html><html><head><style>${css}</style></head><body>` +
`<div id="container"><div class="RecordZone row gx-0"><div class="GeneralLog col col-12 col-lg-6">` +
`<div class="bg1 center s-border-tb title">개인 기록</div>${lines}</div></div></div></body></html>`,
{ waitUntil: 'networkidle' }
);
await page.evaluate(() => document.fonts.ready);
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
const measurement = await page.locator('.fixture-line').evaluateAll((elements) =>
elements.map((element) => {
const spans = [...element.querySelectorAll('span')];
const time = spans.find((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? ''));
const name = spans.find((span) => span.textContent === 'ⓝ염행');
const action = spans.find((span) => span.textContent === '수비' || span.textContent === '공격');
if (
!(time instanceof HTMLElement) ||
!(name instanceof HTMLElement) ||
!(action instanceof HTMLElement)
) {
throw new Error('Ref 개인 공격·수비 기록의 비교 span을 찾지 못했습니다.');
}
const rect = element.getBoundingClientRect();
return {
text: element.textContent,
row: {
width: rect.width,
height: rect.height,
fontSize: getComputedStyle(element).fontSize,
lineHeight: getComputedStyle(element).lineHeight,
},
timeFontSize: getComputedStyle(time).fontSize,
nameFontSize: getComputedStyle(name).fontSize,
actionFontSize: getComputedStyle(action).fontSize,
};
})
);
const output = { viewport, measurement };
console.log(JSON.stringify(output));
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await Promise.all([
page.screenshot({ path: resolve(artifactDir, `ref-personal-war-log-font-${viewport.name}.png`) }),
writeFile(
resolve(artifactDir, `ref-personal-war-log-font-${viewport.name}.json`),
`${JSON.stringify(output, null, 2)}\n`
),
]);
}
}
} finally {
await browser.close();
}
@@ -955,6 +955,63 @@ test.describe('yearbook legacy parity', () => {
});
}
for (const viewport of [
{ name: 'desktop', width: 1365, height: 768, minimumWorldHeight: 128 },
{ name: 'mobile', width: 390, height: 844, minimumWorldHeight: 149 },
]) {
test(`grows record blocks with long content on ${viewport.name}`, async ({ page }) => {
const longWorldHistory = Array.from(
{ length: 12 },
(_, index) => `<C>●</> 중원 정세 긴 기록 ${index + 1}번째 줄입니다.`
);
const longGlobalAction = Array.from(
{ length: 8 },
(_, index) => `<L>●</> 장수 동향 긴 기록 ${index + 1}번째 줄입니다.`
);
await page.route('**/che/api/trpc/**', async (route) => {
if (!operationNames(route).includes('yearbook.getHistory')) {
await route.fallback();
return;
}
await fulfillOperations(route, () => ({
...fixture.game.yearbook,
data: {
...fixture.game.yearbook.data,
globalHistory: longWorldHistory,
globalAction: longGlobalAction,
},
}));
});
await page.setViewportSize(viewport);
await page.goto(gameUrl('/yearbook'));
await expect(page.getByText('중원 정세 긴 기록 12번째 줄입니다.')).toBeVisible();
await expect(page.getByText('장수 동향 긴 기록 8번째 줄입니다.')).toBeVisible();
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, `yearbook-long-content-${viewport.name}.png`),
fullPage: true,
animations: 'disabled',
});
}
const geometry = await page.locator('.history-log').evaluateAll((blocks) =>
blocks.map((block) => {
const element = block as HTMLElement;
return {
height: element.getBoundingClientRect().height,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
};
})
);
expect(geometry[0]?.height).toBeGreaterThan(viewport.minimumWorldHeight);
expect(geometry[1]?.height).toBeGreaterThan(65);
expect(geometry[0]?.clientHeight).toBe(geometry[0]?.scrollHeight);
expect(geometry[1]?.clientHeight).toBe(geometry[1]?.scrollHeight);
});
}
test('shows the history API error while keeping month navigation available', async ({ page }) => {
await page.route('**/che/api/trpc/**', async (route) => {
if (operationNames(route).includes('yearbook.getHistory')) {