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 { TournamentStore } from '../../tournament/store.js';
import { buildTournamentKeys } from '../../tournament/keys.js'; import { buildTournamentKeys } from '../../tournament/keys.js';
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js'; import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { loadCurrentGameTime } from '../../services/gameClock.js'; import { loadCurrentGameTime } from '../../services/gameClock.js';
@@ -412,52 +413,31 @@ export const tournamentRouter = router({
}); });
} }
const settingResult = await ctx.turnDaemon.requestCommand({ const meta = asRecord(general.meta);
type: 'setMySetting', const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
generalId: general.id, const applicant = assignManualApplicantGroup({
settings: { tnmt: 1 }, 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({ await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
reason: 'tournamentJoinRollback', reason: 'tournamentJoinRollback',
adjustments: [{ generalId: general.id, goldDelta: develCost }], 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; throw error;
} }
return { ok: true, count: next.length }; return { ok: true, count: next.length };
+29 -14
View File
@@ -28,13 +28,14 @@ import {
repeatNationTurns, repeatNationTurns,
setGeneralTurn, setGeneralTurn,
setGeneralTurns, setGeneralTurns,
setNationTurn, setNationTurnAtCurrentPosition,
setNationTurns, setNationTurnsAtCurrentPositions,
shiftGeneralTurns, shiftGeneralTurns,
shiftNationTurns, shiftNationTurns,
} from '../../turns/reservedTurns.js'; } from '../../turns/reservedTurns.js';
import { getOwnedGeneral } from '../shared/general.js'; import { getOwnedGeneral } from '../shared/general.js';
import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js'; import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js';
import { buildRefGeneralTargetOptions } from '../../turns/commandTargets.js';
const zPushAmount = z const zPushAmount = z
.number() .number()
@@ -181,9 +182,15 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
}), }),
ctx.db.general.findMany({ ctx.db.general.findMany({
where: { npcState: { lt: 2 } }, select: {
select: { id: true, name: true, nationId: true, cityId: true }, id: true,
orderBy: { id: 'asc' }, name: true,
nationId: true,
cityId: true,
npcState: true,
officerLevel: true,
},
orderBy: [{ npcState: 'asc' }, { name: 'asc' }, { id: 'asc' }],
}), }),
environmentPromise, environmentPromise,
loadBattleSimTraitOptions(), loadBattleSimTraitOptions(),
@@ -192,7 +199,13 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
]); ]);
const nationById = new Map(nations.map((entry) => [entry.id, entry])); 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'] = { const items: TurnCommandInputOptions['items'] = {
horse: [{ value: 'None', label: '판매/해제' }], horse: [{ value: 'None', label: '판매/해제' }],
weapon: [{ value: 'None', label: '판매/해제' }], weapon: [{ value: 'None', label: '판매/해제' }],
@@ -226,12 +239,8 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
label: entry.name, label: entry.name,
color: entry.color, color: entry.color,
})), })),
generals: generals.map((entry) => ({ generals: generalTargetOptions.generals,
value: entry.id, generalTargets: generalTargetOptions.generalTargets,
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무소속'} · ${
cityById.get(entry.cityId)?.name ?? '재야'
})`,
})),
crewTypes: (environment.unitSet.crewTypes ?? []) crewTypes: (environment.unitSet.crewTypes ?? [])
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible')) .filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
.map((entry) => ({ value: entry.id, label: entry.name })), .map((entry) => ({ value: entry.id, label: entry.name })),
@@ -446,7 +455,7 @@ export const turnsRouter = router({
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args); await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
const snapshot = await mutateReservedTurns(() => const snapshot = await mutateReservedTurns(() =>
setNationTurn( setNationTurnAtCurrentPosition(
ctx.db, ctx.db,
general.nationId, general.nationId,
general.officerLevel, general.officerLevel,
@@ -559,7 +568,13 @@ export const turnsRouter = router({
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args); await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
} }
const snapshot = await mutateReservedTurns(() => 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 }; 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 => const selectWeighted = <T>(rng: ReturnType<typeof createTournamentRng>, pool: Array<{ item: T; weight: number }>): T =>
rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight])); rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight]));
+1
View File
@@ -71,6 +71,7 @@ export interface TurnCommandInputOptions {
cities: TurnCommandOption[]; cities: TurnCommandOption[];
nations: TurnCommandOption[]; nations: TurnCommandOption[];
generals: TurnCommandOption[]; generals: TurnCommandOption[];
generalTargets?: Record<string, TurnCommandOption[]>;
crewTypes: TurnCommandOption[]; crewTypes: TurnCommandOption[];
armTypes: TurnCommandOption[]; armTypes: TurnCommandOption[];
nationTypes: TurnCommandOption[]; nationTypes: TurnCommandOption[];
+1
View File
@@ -771,6 +771,7 @@ export const buildTurnCommandTable = async (options: {
cities: [], cities: [],
nations: [], nations: [],
generals: [], generals: [],
generalTargets: {},
crewTypes: [], crewTypes: [],
armTypes: [], armTypes: [],
nationTypes: [], 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) }; 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 ( export const setNationTurn = async (
db: DatabaseClient, db: DatabaseClient,
nationId: number, nationId: number,
@@ -409,6 +435,23 @@ export const setNationTurn = async (
): Promise<ReservedTurnSnapshot> => ): Promise<ReservedTurnSnapshot> =>
setNationTurns(db, nationId, officerLevel, [{ turnIndices: [turnIndex], action, args }], expectedRevision); 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 ( export const shiftNationTurns = async (
db: DatabaseClient, db: DatabaseClient,
nationId: number, 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, setGeneralTurn,
setGeneralTurns, setGeneralTurns,
setNationTurn, setNationTurn,
setNationTurnAtCurrentPosition,
setNationTurns, setNationTurns,
setNationTurnsAtCurrentPositions,
shiftGeneralTurns, shiftGeneralTurns,
shiftNationTurns, shiftNationTurns,
ReservedTurnRevisionConflictError, ReservedTurnRevisionConflictError,
@@ -195,7 +197,7 @@ const buildDb = (autorunLimit: number | null = null) => {
}, },
} as unknown as DatabaseClient; } as unknown as DatabaseClient;
return { db }; return { db, nationTurns, nationRevisions };
}; };
describe('reservedTurns', () => { describe('reservedTurns', () => {
@@ -343,6 +345,61 @@ describe('reservedTurns', () => {
expect(noOpPush).toEqual(repeated); 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 () => { it('rejects an API writer while the daemon holds the queue lease without touching turns', async () => {
const deleteMany = vi.fn(async () => ({})); const deleteMany = vi.fn(async () => ({}));
const createMany = vi.fn(async () => ({})); const createMany = vi.fn(async () => ({}));
@@ -371,4 +428,35 @@ describe('reservedTurns', () => {
expect(deleteMany).not.toHaveBeenCalled(); expect(deleteMany).not.toHaveBeenCalled();
expect(createMany).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[0]?.args).toEqual({ isGold: true, amount: 1, destGeneralId: 7 });
expect(response.turns[2]?.args).toEqual({ isGold: false, amount: 2, destGeneralId: 8 }); 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 () => { 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.gold.get(general.id)).toBe(1_800);
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1); 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 () => { it('serializes concurrent bets and enforces the legacy per-user 1000 limit', async () => {
+53 -1
View File
@@ -12,7 +12,12 @@ import type {
TournamentState, TournamentState,
} from '../src/tournament/types.js'; } from '../src/tournament/types.js';
import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.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'; import type { TurnDaemonTransport } from '../src/daemon/transport.js';
class MemoryRedis { class MemoryRedis {
@@ -226,6 +231,45 @@ const runTournamentToCompletion = async (options: {
const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0)); const delayTick = async (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
describe('tournament worker schedule compatibility', () => { 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', () => { it('catches up from the stored schedule instead of discarding elapsed legacy phases', () => {
const state = createTournamentState({ const state = createTournamentState({
termSeconds: 600, 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 === 99)).toBe(false);
expect(participants.some((entry) => entry.id === 1001)).toBe(true); expect(participants.some((entry) => entry.id === 1001)).toBe(true);
expect(participants.some((entry) => entry.id < 0)).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); await store.setState(afterJoin);
const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' }); const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' });
+109 -5
View File
@@ -53,6 +53,18 @@ const inputOptions = {
{ value: 1, label: '장수 (아국 · 업)' }, { value: 1, label: '장수 (아국 · 업)' },
{ value: 2, 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: '보병' }], crewTypes: [{ value: 1100, label: '보병' }],
armTypes: [{ value: 1, label: '보병' }], armTypes: [{ value: 1, label: '보병' }],
nationTypes: [{ value: 'che_도적', label: '도적', description: '금 수입 증가, 쌀 수입 감소' }], nationTypes: [{ value: 'che_도적', label: '도적', description: '금 수입 증가, 쌀 수입 감소' }],
@@ -389,8 +401,8 @@ const chiefCenter = {
maxTurns: 12, maxTurns: 12,
chiefs: [12, 10, 8, 6, 11, 9, 7, 5].map((officerLevel) => ({ chiefs: [12, 10, 8, 6, 11, 9, 7, 5].map((officerLevel) => ({
officerLevel, officerLevel,
name: officerLevel === 5 ? '장수' : null, name: officerLevel === 5 ? '장수' : `수뇌${officerLevel}`,
npcState: officerLevel === 5 ? 0 : null, npcState: officerLevel === 8 ? 2 : 0,
turnTime: null, turnTime: null,
revision: 0, revision: 0,
turns: turns(12), 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') }); 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 }) => { test('enters general and nation command arguments and sends exact values', async ({ page }) => {
const requests = await install(page); const requests = await install(page);
await page.setViewportSize({ width: 1200, height: 900 }); 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'); const chiefForm = chiefPicker.getByTestId('command-argument-form');
await chiefForm.getByRole('button', { name: '쌀' }).click(); await chiefForm.getByRole('button', { name: '쌀' }).click();
await chiefForm.locator('input[type=number]').fill('300'); 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 geometry = await chiefForm.evaluate((element) => {
const row = element.querySelector('.argument-row'); const row = element.querySelector('.argument-row');
const rect = element.getBoundingClientRect(); 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 chiefPicker.getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText( 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('"destCityId":2');
expect(JSON.stringify(requests)).toContain('"isGold":false'); expect(JSON.stringify(requests)).toContain('"isGold":false');
expect(JSON.stringify(requests)).toContain('"amount":300'); 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.width).toBeGreaterThan(650);
expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2); 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.fontFamily).toContain('Pretendard');
expect(pageStyle.fontSize).toBe('14px'); 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'); const names = page.locator('.general-names');
await expect(names).toHaveCSS('white-space', 'normal'); await expect(names).toHaveCSS('white-space', 'normal');
const nameLineCount = await names.locator('span').evaluateAll((elements) => { 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); 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 }) => { test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] }; const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
await install(page, state); 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 }); 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) => { await page.addInitScript((profile) => {
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright'); window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
window.localStorage.setItem('sammo-game-profile', profile); window.localStorage.setItem('sammo-game-profile', profile);
@@ -141,7 +142,7 @@ const installFixture = async (page: Page) => {
if (operation === 'tournament.getSnapshot') { if (operation === 'tournament.getSnapshot') {
return response({ return response({
state: { state: {
stage: 0, stage: options.applicationOpen ? 1 : 0,
phase: 0, phase: 0,
type: 0, type: 0,
auto: false, auto: false,
@@ -151,11 +152,32 @@ const installFixture = async (page: Page) => {
nextAt: '2026-08-02T00:00:00.000Z', nextAt: '2026-08-02T00:00:00.000Z',
winnerId: 1, 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, matches,
betCount: 16, betCount: 16,
}); });
} }
if (operation === 'tournament.join') {
joined = true;
return response({ ok: true, count: 1 });
}
if (operation === 'tournament.getBettingSummary') { if (operation === 'tournament.getBettingSummary') {
return response({ return response({
totals: Object.fromEntries( 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(geometry.horizontalIdentities).toBe(true);
expect(Math.abs(geometry.firstParentY - geometry.firstPairAverageY)).toBeLessThan(1); 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')); 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 ({ test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({
page, page,
}, testInfo) => { }, 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.nameLeft).toBeGreaterThanOrEqual(identity.iconRight - 1);
expect(identity.nameTop).toBeLessThan(identity.iconBottom); expect(identity.nameTop).toBeLessThan(identity.iconBottom);
expect(identity.nameBottom).toBeGreaterThan(identity.iconTop); 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 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');
+1
View File
@@ -1,3 +1,4 @@
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
@import 'tailwindcss'; @import 'tailwindcss';
@import './styles/tokens.css'; @import './styles/tokens.css';
@import './styles/legacy-controls.css'; @import './styles/legacy-controls.css';
@@ -685,7 +685,9 @@ const clickOutsideMenu = (event: Event) => {
class="command-picker" class="command-picker"
:class="{ 'recruitment-picker': isRecruitmentCommand }" :class="{ 'recruitment-picker': isRecruitmentCommand }"
data-testid="command-picker" data-testid="command-picker"
:style="isRecruitmentCommand || props.compact ? undefined : { top: quickPickerTop }" :style="
isRecruitmentCommand || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }
"
:role="isRecruitmentCommand ? 'dialog' : undefined" :role="isRecruitmentCommand ? 'dialog' : undefined"
:aria-modal="isRecruitmentCommand ? 'true' : undefined" :aria-modal="isRecruitmentCommand ? 'true' : undefined"
:aria-label=" :aria-label="
@@ -1146,12 +1148,15 @@ const clickOutsideMenu = (event: Event) => {
} }
.mobile.compact .editor-layout { .mobile.compact .editor-layout {
height: 360px; min-height: 370px;
display: grid; display: grid;
grid-template-columns: 109px 391px; grid-template-columns: 109px 391px;
grid-template-rows: auto auto;
} }
.mobile.compact .control-pad { .mobile.compact .control-pad {
order: initial; order: initial;
grid-column: 1;
grid-row: 1 / -1;
min-height: 0; min-height: 0;
padding: 0; padding: 0;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1159,6 +1164,8 @@ const clickOutsideMenu = (event: Event) => {
} }
.mobile.compact .queue-area { .mobile.compact .queue-area {
order: initial; order: initial;
grid-column: 2;
grid-row: 1;
padding-top: 10px; padding-top: 10px;
} }
.mobile.compact .queue-grid { .mobile.compact .queue-grid {
@@ -1189,8 +1196,8 @@ const clickOutsideMenu = (event: Event) => {
overflow: visible; overflow: visible;
} }
.mobile.compact .advanced-actions { .mobile.compact .advanced-actions {
right: 0; position: static;
bottom: 0; grid-column: 2;
left: 109px; grid-row: 2;
} }
</style> </style>
@@ -75,7 +75,10 @@ export const formatReservedCommandBrief = (
const commandName = commandNames.get(action) ?? defaultCommandName(action); const commandName = commandNames.get(action) ?? defaultCommandName(action);
const cityName = optionLabel(input?.cities ?? [], firstValue(args, ['destCityId', 'destCityID'])); const cityName = optionLabel(input?.cities ?? [], firstValue(args, ['destCityId', 'destCityID']));
const nationName = optionLabel(input?.nations ?? [], firstValue(args, ['destNationId', 'destNationID'])); 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) { if (CITY_TO_COMMANDS.has(action) && cityName) {
return `${wrappedWithParticle(cityName, '으로')} ${commandName}`; return `${wrappedWithParticle(cityName, '으로')} ${commandName}`;
@@ -95,6 +95,7 @@ export type CommandTable = {
cities: CommandOption[]; cities: CommandOption[];
nations: CommandOption[]; nations: CommandOption[];
generals: CommandOption[]; generals: CommandOption[];
generalTargets?: Record<string, CommandOption[]>;
crewTypes: CommandOption[]; crewTypes: CommandOption[];
armTypes: CommandOption[]; armTypes: CommandOption[];
nationTypes: CommandOption[]; nationTypes: CommandOption[];
@@ -33,6 +33,9 @@ const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !
const optionsFor = (field: CommandInputField): CommandOption[] => { const optionsFor = (field: CommandInputField): CommandOption[] => {
if (field.options) return field.options; if (field.options) return field.options;
if (!field.optionSource) return []; if (!field.optionSource) return [];
if (field.optionSource === 'generals') {
return props.options.generalTargets?.[props.commandKey] ?? props.options.generals;
}
if (field.optionSource === 'items') { if (field.optionSource === 'items') {
return props.options.items[String(values.itemType ?? '')] ?? []; return props.options.items[String(values.itemType ?? '')] ?? [];
} }
@@ -41,7 +41,7 @@ const formattedLogs = computed(() =>
.recent-log-list { .recent-log-list {
min-width: 0; min-width: 0;
color: #fff; color: #fff;
font-family: 'Times New Roman', serif; font-family: var(--sammo-font-sans);
font-size: 14px; font-size: 14px;
line-height: 1.35; line-height: 1.35;
} }
@@ -28,8 +28,10 @@ const roundColumns = computed(() => [
]); ]);
const desktopX = [110, 355, 600, 845, 1090]; const desktopX = [110, 355, 600, 845, 1090];
const cardWidth = 190; const cardWidth = 190;
const desktopSlotHeight = 88;
const desktopCanvasHeight = desktopSlotHeight * 16;
const slotY = (columnIndex: number, slotIndex: number) => { const slotY = (columnIndex: number, slotIndex: number) => {
const slotHeight = 72 * 2 ** columnIndex; const slotHeight = desktopSlotHeight * 2 ** columnIndex;
return slotHeight / 2 + slotIndex * slotHeight; return slotHeight / 2 + slotIndex * slotHeight;
}; };
const connections = computed(() => const connections = computed(() =>
@@ -77,8 +79,8 @@ const mobilePairs = computed(() => {
<div class="desktop-round-labels" aria-hidden="true"> <div class="desktop-round-labels" aria-hidden="true">
<strong v-for="label in roundLabels" :key="label">{{ label }}</strong> <strong v-for="label in roundLabels" :key="label">{{ label }}</strong>
</div> </div>
<div class="desktop-bracket-canvas"> <div class="desktop-bracket-canvas" :style="{ height: `${desktopCanvasHeight}px` }">
<svg viewBox="0 0 1200 1152" aria-hidden="true"> <svg :viewBox="`0 0 1200 ${desktopCanvasHeight}`" aria-hidden="true">
<g v-for="connection in connections" :key="connection.id"> <g v-for="connection in connections" :key="connection.id">
<path <path
class="bracket-connector" class="bracket-connector"
@@ -177,7 +179,6 @@ const mobilePairs = computed(() => {
.desktop-bracket-canvas { .desktop-bracket-canvas {
position: relative; position: relative;
width: 100%; width: 100%;
height: 1152px;
} }
.desktop-bracket-canvas svg { .desktop-bracket-canvas svg {
position: absolute; position: absolute;
@@ -200,7 +201,7 @@ const mobilePairs = computed(() => {
display: grid; display: grid;
box-sizing: border-box; box-sizing: border-box;
width: clamp(140px, 16vw, 190px); width: clamp(140px, 16vw, 190px);
min-height: 68px; min-height: 82px;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
@@ -265,7 +266,7 @@ const mobilePairs = computed(() => {
.mobile-bracket-name { .mobile-bracket-name {
box-sizing: border-box; box-sizing: border-box;
min-width: 0; min-width: 0;
min-height: 68px; min-height: 82px;
overflow: hidden; overflow: hidden;
border: 1px solid #555; border: 1px solid #555;
background: rgb(58 33 24 / 94%); 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); background: #142b42 var(--sammo-texture-blue);
} }
.title { .title {
height: 55.6875px; min-height: 68px;
padding: 0; padding: 0;
font-size: 14px; font-size: 14px;
line-height: 19.1875px; line-height: 19.1875px;
} }
.close-button { .close-button {
display: block; display: block;
width: 62px; width: 88px;
height: 35.5px; height: 44px;
padding: 8px 12px; padding: 10px 16px;
border: 1px solid #375a7f; border: 1px solid #375a7f;
border-radius: 5.25px; border-radius: 5.25px;
background: #375a7f; background: #375a7f;
@@ -323,10 +323,16 @@ const placeBet = async (targetId: number) => {
text-decoration: none; text-decoration: none;
} }
.toolbar { .toolbar {
min-height: 36.5px; min-height: 46px;
padding: 1px; padding: 1px;
text-align: left; text-align: left;
} }
.toolbar button {
min-width: 72px;
height: 44px;
padding: 10px 16px;
font-size: 14px;
}
.error { .error {
min-height: 32px; min-height: 32px;
padding: 5px; padding: 5px;
+1 -3
View File
@@ -252,9 +252,7 @@ onMounted(() => {
padding: 8px; padding: 8px;
color: #000; color: #000;
background: #fff; background: #fff;
font: font: 16px/normal var(--sammo-font-sans);
16px/normal 'Times New Roman',
serif;
} }
.legacy-board-page { .legacy-board-page {
+63 -25
View File
@@ -352,22 +352,34 @@ const repeatTurns = async (amount: number) => {
</div> </div>
<div class="chief-overview-frame"> <div class="chief-overview-frame">
<div class="chief-overview"> <div class="chief-overview">
<template v-for="chief in chiefViews" :key="chief.officerLevel"> <div
<ChiefTurnCard v-for="(rowChiefs, rowIndex) in [chiefViews.slice(0, 4), chiefViews.slice(4, 8)]"
v-if="chief.name" :key="rowIndex"
:officer-level-text="chief.officerLevelText" class="chief-overview-row"
:name="chief.name" >
:npc-state="chief.npcState" <div class="overview-turn-index legacy-bg0" :aria-label="`${rowIndex + 1} 번호`">
:rows="chief.rows" <span></span><span v-for="idx in data.maxTurns" :key="idx">{{ idx }}</span>
:compact="true" </div>
:selected="chief.officerLevel === selectedChief?.officerLevel" <template v-for="chief in rowChiefs" :key="chief.officerLevel">
:is-me="chief.officerLevel === data.me.officerLevel" <ChiefTurnCard
:clickable="true" v-if="chief.name"
:turn-time-label="chief.rows[0]?.time" :officer-level-text="chief.officerLevelText"
@select="selectedChiefLevel = chief.officerLevel" :name="chief.name"
/> :npc-state="chief.npcState"
<div v-else class="empty-chief-slot" aria-hidden="true"></div> :rows="chief.rows"
</template> :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>
</div> </div>
</section> </section>
@@ -750,20 +762,25 @@ const repeatTurns = async (amount: number) => {
.chief-overview-frame { .chief-overview-frame {
width: 500px; width: 500px;
height: 310px; height: 310px;
margin-top: -3px; margin-top: 0;
margin-bottom: 11px; margin-bottom: 11px;
overflow: hidden; overflow: hidden;
} }
.chief-overview { .chief-overview {
width: 445px; width: 500px;
height: 310px; height: 310px;
margin-top: 0; margin-top: 0;
display: flex;
flex-direction: column;
}
.chief-overview-row {
width: 500px;
height: 155px;
display: grid; display: grid;
grid-template-columns: repeat(4, 111.25px); grid-template-columns: 12px repeat(4, 119px) 12px;
grid-auto-rows: 155px;
} }
.chief-overview :deep(.chief-card) { .chief-overview :deep(.chief-card) {
width: 111.25px; width: 119px;
height: 155px; height: 155px;
border: 0; border: 0;
border-left: 1px solid #fff; border-left: 1px solid #fff;
@@ -787,13 +804,34 @@ const repeatTurns = async (amount: number) => {
box-sizing: border-box; box-sizing: border-box;
height: 20px !important; height: 20px !important;
min-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-time),
.chief-overview :deep(.row-action) { .chief-overview :deep(.row-action) {
display: grid; display: grid;
place-items: center; 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 { .mobile-readonly {
width: 404px; width: 404px;
height: 420px; height: 420px;
@@ -879,7 +917,7 @@ const repeatTurns = async (amount: number) => {
@media (max-width: 1024px) { @media (max-width: 1024px) {
.chief-overview { .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 { .chief-grid-row > .empty-chief-slot {
height: 384px; height: 384px;
} }
.chief-overview > .empty-chief-slot { .chief-overview-row > .empty-chief-slot {
width: 111.25px; width: 119px;
height: 155px; height: 155px;
} }
</style> </style>
@@ -366,7 +366,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
border: 1px solid #767676; border: 1px solid #767676;
background: #6b6b6b; background: #6b6b6b;
color: #fff; color: #fff;
font-family: Arial, sans-serif; font-family: var(--sammo-font-sans);
font-size: 13.3333px; font-size: 13.3333px;
} }
.selector { .selector {
@@ -470,7 +470,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
background: #6c757d; background: #6c757d;
color: #fff; color: #fff;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
font-family: Arial, sans-serif; font-family: var(--sammo-font-sans);
font-size: 14px; font-size: 14px;
line-height: 1; line-height: 1;
text-decoration: none; text-decoration: none;
@@ -305,7 +305,7 @@ onMounted(loadDetail);
width: 1000px; width: 1000px;
margin: 8px auto 0; margin: 8px auto 0;
color: #fff; color: #fff;
font-family: 'Times New Roman', serif; font-family: var(--sammo-font-sans);
font-size: 16px; font-size: 16px;
line-height: normal; line-height: normal;
} }
@@ -384,7 +384,7 @@ onMounted(loadDetail);
padding: 1px 6px; padding: 1px 6px;
background: buttonface; background: buttonface;
color: buttontext; color: buttontext;
font-family: Arial; font-family: var(--sammo-font-sans);
font-size: 13.3333px; font-size: 13.3333px;
font-weight: 400; font-weight: 400;
line-height: normal; line-height: normal;
@@ -231,7 +231,7 @@ onMounted(loadDynasty);
padding: 1px 6px; padding: 1px 6px;
background: buttonface; background: buttonface;
color: buttontext; color: buttontext;
font-family: Arial; font-family: var(--sammo-font-sans);
font-size: 13.3333px; font-size: 13.3333px;
font-weight: 400; font-weight: 400;
line-height: normal; line-height: normal;
+1 -6
View File
@@ -778,12 +778,7 @@ onMounted(() => {
padding: 0 7px; padding: 0 7px;
color: #fff; color: #fff;
height: 1597px; height: 1597px;
font: font: 14px/21px var(--sammo-font-sans);
14px/21px Pretendard,
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
sans-serif;
} }
.inherit-page.legacy-bg0 { .inherit-page.legacy-bg0 {
+3 -2
View File
@@ -66,11 +66,12 @@ const nationAccess = computed(() => ({
})); }));
const nationColor = computed(() => nation.value?.color ?? '#000000'); const nationColor = computed(() => nation.value?.color ?? '#000000');
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote)); 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 => { 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: '' }); const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' });
if (!time) return formatLog(entry.text); if (!time) return formatLog(entry.text);
return formatLog(`${entry.text} ${time}`); return formatLog(`${entry.text} <1>${time}</>`);
}; };
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null; let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
@@ -451,7 +451,7 @@ onMounted(() => {
width: 500px; width: 500px;
margin: 0 auto; margin: 0 auto;
color: #fff; color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic'; font-family: var(--sammo-font-sans);
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
} }
@@ -522,12 +522,7 @@ onMounted(() => void loadPersonnel());
min-height: 100vh; min-height: 100vh;
margin: 0 auto; margin: 0 auto;
color: #fff; color: #fff;
font: font: 14px/1.3 var(--sammo-font-sans);
14px/1.3 Pretendard,
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
sans-serif;
} }
.legacy-table { .legacy-table {
width: 1000px; width: 1000px;
@@ -174,12 +174,7 @@ onMounted(load);
.secret-page { .secret-page {
width: auto; width: auto;
margin: 0; margin: 0;
font: font: 14px var(--sammo-font-sans);
14px Pretendard,
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
sans-serif;
color: #fff; color: #fff;
} }
.layout { .layout {
@@ -437,12 +437,7 @@ onMounted(() => void loadStratFinan());
margin: 0 auto; margin: 0 auto;
color: #fff; color: #fff;
background: var(--sammo-texture-walnut); background: var(--sammo-texture-walnut);
font: font: 14px/1.3 var(--sammo-font-sans);
14px/1.3 Pretendard,
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
sans-serif;
} }
.tiptap-compat-controls { .tiptap-compat-controls {
display: none; display: none;
+1 -1
View File
@@ -165,7 +165,7 @@ onMounted(() => {
min-height: 100vh; min-height: 100vh;
margin: 0 auto; margin: 0 auto;
color: #fff; color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic'; font-family: var(--sammo-font-sans);
font-size: 14px; font-size: 14px;
line-height: 1.3; line-height: 1.3;
} }
@@ -515,8 +515,6 @@ onBeforeUnmount(() => {
</template> </template>
<style scoped> <style scoped>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
.select-pool-page { .select-pool-page {
width: 1000px; width: 1000px;
min-width: 1000px; min-width: 1000px;
+1 -1
View File
@@ -423,7 +423,7 @@ onMounted(() => {
.pageVote { .pageVote {
margin: 0 auto; margin: 0 auto;
color: #fff; color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic'; font-family: var(--sammo-font-sans);
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
} }
+11 -5
View File
@@ -385,16 +385,16 @@ const start = async () => {
background: #142b42 var(--sammo-texture-blue); background: #142b42 var(--sammo-texture-blue);
} }
.legacy-title { .legacy-title {
height: 55.6875px; min-height: 68px;
padding: 0; padding: 0;
font-size: 14px; font-size: 14px;
line-height: 19.1875px; line-height: 19.1875px;
} }
.close-button { .close-button {
display: block; display: block;
width: 62px; width: 88px;
height: 35.5px; height: 44px;
padding: 8px 12px; padding: 10px 16px;
border: 1px solid #375a7f; border: 1px solid #375a7f;
border-radius: 5.25px; border-radius: 5.25px;
background: #375a7f; background: #375a7f;
@@ -404,9 +404,15 @@ const start = async () => {
text-decoration: none; text-decoration: none;
} }
.toolbar { .toolbar {
min-height: 36.5px; min-height: 46px;
padding: 1px; padding: 1px;
} }
.toolbar button {
min-width: 72px;
height: 44px;
padding: 10px 16px;
font-size: 14px;
}
.operator-row, .operator-row,
.state-row, .state-row,
.error-row, .error-row,
+1 -1
View File
@@ -361,7 +361,7 @@ onMounted(() => {
margin: 0 auto; margin: 0 auto;
color: #fff; color: #fff;
background: transparent; background: transparent;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic'; font-family: var(--sammo-font-sans);
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
} }
+3 -3
View File
@@ -378,10 +378,10 @@ onMounted(async () => {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.history-log:first-of-type { .history-log:first-of-type {
height: 128px; min-height: 128px;
} }
.history-log:last-of-type { .history-log:last-of-type {
height: 65px; min-height: 65px;
} }
.dropdown-compat-buttons { .dropdown-compat-buttons {
display: none; display: none;
@@ -441,7 +441,7 @@ onMounted(async () => {
order: 4; order: 4;
} }
.history-log:first-of-type { .history-log:first-of-type {
height: 149px; min-height: 149px;
margin-bottom: 0; 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: 8, label: '손권 (오 · 단양)' },
{ value: 9, label: '조조 (위 · 업)' }, { value: 9, label: '조조 (위 · 업)' },
], ],
generalTargets: {
che_포상: [
{ value: 8, label: '손권 (오 · 단양)' },
{ value: 10, label: '여포NPC (오 · 단양)' },
],
},
crewTypes: [{ value: 1100, label: '보병' }], crewTypes: [{ value: 1100, label: '보병' }],
armTypes: [ armTypes: [
{ value: 0, label: '보병' }, { value: 0, label: '보병' },
@@ -165,6 +171,10 @@ test('국가 명령의 도시·국가·장수·자원 인자를 Ref brief로 표
for (const [action, args, expected] of cases) { for (const [action, args, expected] of cases) {
assert.equal(formatReservedCommandBrief('nation', action, args, table), expected, action); 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를 재정의하지 않은 명령은 실제 표시명을 유지한다', () => { 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 Pixel snapshots may be added after these structural assertions pass. Dynamic
regions must not be hidden merely to make a pixel threshold pass. 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 To refresh the PHP ranking evidence after building the ignored reference
webpack assets, run: webpack assets, run:
@@ -221,19 +221,19 @@ test('dynasty list matches the ref Chromium table geometry and interactions', as
{ y: 65, height: 37 }, { y: 65, height: 37 },
{ y: 112, height: 139 }, { y: 112, height: 139 },
]); ]);
expect(geometry.tables[0]!.fontFamily).toContain('Times New Roman'); expect(geometry.tables[0]!.fontFamily).toContain('Pretendard');
expect(geometry.tables[0]!.fontSize).toBe('16px'); expect(geometry.tables[0]!.fontSize).toBe('14px');
expect(geometry.tables[0]!.lineHeight).toBe('normal'); expect(geometry.tables[0]!.lineHeight).toBe('18.2px');
expect(geometry.button).toEqual({ expect(geometry.button.fontFamily).toContain('Pretendard');
expect(geometry.button).toMatchObject({
height: 22, height: 22,
borderWidth: '2px', borderWidth: '2px',
borderRadius: '0px', borderRadius: '0px',
padding: '1px 6px', padding: '1px 6px',
fontFamily: 'Arial',
fontSize: '13.3333px', fontSize: '13.3333px',
cursor: 'default', 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(); const historyLink = page.getByRole('link', { name: '역사 보기' }).last();
await expect(historyLink).toHaveAttribute('href', '/che/yearbook?serverID=hwe_260725_u3uE'); 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 first = container.querySelector<HTMLTableElement>('table')!.getBoundingClientRect();
const emperor = container.querySelector<HTMLTableElement>('.emperor-table')!.getBoundingClientRect(); const emperor = container.querySelector<HTMLTableElement>('.emperor-table')!.getBoundingClientRect();
const oldNation = container.querySelector<HTMLTableElement>('.old-nation-table')!.getBoundingClientRect(); const oldNation = container.querySelector<HTMLTableElement>('.old-nation-table')!.getBoundingClientRect();
const containerStyle = getComputedStyle(container);
const buttonStyle = getComputedStyle(container.querySelector<HTMLButtonElement>('button')!);
return { return {
container: { x: rect.x, y: rect.y, width: rect.width }, container: { x: rect.x, y: rect.y, width: rect.width },
first: { x: first.x, y: first.y, width: first.width, height: first.height }, first: { x: first.x, y: first.y, width: first.width, height: first.height },
emperor: { x: emperor.x, y: emperor.y, width: emperor.width }, emperor: { x: emperor.x, y: emperor.y, width: emperor.width },
oldNation: { x: oldNation.x, width: oldNation.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.container).toEqual({ x: 140, y: 8, width: 1000 });
expect(geometry.first).toEqual({ x: 140, y: 8, width: 1000, height: 47 }); expect(geometry.first).toEqual({ x: 140, y: 8, width: 1000, height: 47 });
expect(geometry.emperor).toEqual({ x: 140, y: 55, width: 1000 }); expect(geometry.emperor).toEqual({ x: 140, y: 55, width: 1000 });
expect(geometry.oldNation).toEqual({ x: 140, 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 page.goto(`${gameOrigin}/che/dynasty/999`);
await expect(page.getByRole('alert')).toHaveText('왕조 정보를 찾을 수 없습니다.'); 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, 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.getCommandTable') return response({ general: [], nation: [] });
if (operation === 'turns.reserved.getGeneral') return response([]); if (operation === 'turns.reserved.getGeneral') return response([]);
if (operation === 'messages.getRecent') return response(emptyMessages); 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 }) => { test('keeps the current map visible when the recent record request fails', async ({ page }) => {
await installMainFixture(page, true); await installMainFixture(page, true);
await page.setViewportSize({ width: 1440, height: 1000 }); 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 }) => { test('shows the history API error while keeping month navigation available', async ({ page }) => {
await page.route('**/che/api/trpc/**', async (route) => { await page.route('**/che/api/trpc/**', async (route) => {
if (operationNames(route).includes('yearbook.getHistory')) { if (operationNames(route).includes('yearbook.getHistory')) {