merge: 최신 main을 비용 기반 갱신 점수에 통합

This commit is contained in:
2026-08-17 11:10:43 +00:00
68 changed files with 2128 additions and 306 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { isAvailableNationTraitKey } from '@sammo-ts/logic';
import type { BattleSimRequestPayload } from './types.js'; import type { BattleSimRequestPayload } from './types.js';
const zBattleSimGeneral = z.object({ const zBattleSimGeneral = z.object({
@@ -71,7 +73,7 @@ const zBattleSimCity = z.object({
}); });
const zBattleSimNation = z.object({ const zBattleSimNation = z.object({
type: z.string().min(1), type: z.string().refine(isAvailableNationTraitKey),
tech: z.number().min(0), tech: z.number().min(0),
level: z.number().int().min(0), level: z.number().int().min(0),
capital: z.number().int().min(0), capital: z.number().int().min(0),
@@ -1,4 +1,5 @@
import { import {
AVAILABLE_NATION_TRAIT_KEYS,
ITEM_KEYS, ITEM_KEYS,
EVENT_DOMESTIC_TRAIT_KEYS, EVENT_DOMESTIC_TRAIT_KEYS,
loadEventDomesticTraitModules, loadEventDomesticTraitModules,
@@ -6,7 +7,6 @@ import {
loadNationTraitModules, loadNationTraitModules,
loadPersonalityTraitModules, loadPersonalityTraitModules,
loadWarTraitModules, loadWarTraitModules,
NATION_TRAIT_KEYS,
PERSONALITY_TRAIT_KEYS, PERSONALITY_TRAIT_KEYS,
WAR_TRAIT_KEYS, WAR_TRAIT_KEYS,
type ItemModule, type ItemModule,
@@ -110,7 +110,7 @@ export const loadBattleSimTraitOptions = async (): Promise<{
}> => { }> => {
if (!cachedTraitOptions) { if (!cachedTraitOptions) {
cachedTraitOptions = Promise.all([ cachedTraitOptions = Promise.all([
loadNationTraitModules([...NATION_TRAIT_KEYS]), loadNationTraitModules([...AVAILABLE_NATION_TRAIT_KEYS]),
loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS]), loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS]),
loadWarTraitModules([...WAR_TRAIT_KEYS]), loadWarTraitModules([...WAR_TRAIT_KEYS]),
loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]), loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]),
+10 -30
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({
type: 'setMySetting',
generalId: general.id,
settings: { tnmt: 1 },
});
if (!settingResult || settingResult.type !== 'setMySetting' || !settingResult.ok) {
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 meta = asRecord(general.meta);
const level = typeof meta.explevel === 'number' ? meta.explevel : 0; const level = typeof meta.explevel === 'number' ? meta.explevel : 0;
const next = participants.concat({ const applicant = assignManualApplicantGroup({
state,
baseSeed: String(asRecord(worldState?.meta).hiddenSeed ?? 'tournament'),
current: participants,
applicant: {
id: general.id, id: general.id,
name: general.name, name: general.name,
leadership: general.leadership, leadership: general.leadership,
strength: general.strength, strength: general.strength,
intel: general.intel, intel: general.intel,
level, level,
},
}); });
const next = participants.concat(applicant);
try { try {
await store.setParticipants(next); await store.setParticipants(next);
} catch (error) { } catch (error) {
await Promise.all([ await ctx.turnDaemon.requestCommand({
ctx.turnDaemon.requestCommand({
type: 'adjustGeneralResources', type: 'adjustGeneralResources',
reason: 'tournamentJoinRollback', reason: 'tournamentJoinRollback',
adjustments: [{ generalId: general.id, goldDelta: develCost }], 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,
};
};
+55 -10
View File
@@ -165,20 +165,22 @@ const loadGeneralTurns = async (db: DatabaseClient, generalId: number): Promise<
return buildTurnListFromRows(rows, MAX_GENERAL_TURNS); return buildTurnListFromRows(rows, MAX_GENERAL_TURNS);
}; };
const loadGeneralAutorunLimit = async (db: DatabaseClient, generalId: number): Promise<number | null> => {
const general = await db.general.findUnique({ where: { id: generalId }, select: { meta: true } });
const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined;
return typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit) ? Math.trunc(rawAutorunLimit) : null;
};
export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnSnapshot> => { export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnSnapshot> => {
const [turns, revisionRow, general] = await Promise.all([ const [turns, revisionRow, autorunLimit] = await Promise.all([
loadGeneralTurns(db, generalId), loadGeneralTurns(db, generalId),
db.generalTurnRevision.findUnique({ where: { generalId } }), db.generalTurnRevision.findUnique({ where: { generalId } }),
db.general.findUnique({ where: { id: generalId }, select: { meta: true } }), loadGeneralAutorunLimit(db, generalId),
]); ]);
const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined;
return { return {
revision: revisionRow?.revision ?? 0, revision: revisionRow?.revision ?? 0,
turns: serializeTurnList(turns), turns: serializeTurnList(turns),
autorunLimit: autorunLimit,
typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit)
? Math.trunc(rawAutorunLimit)
: null,
}; };
}; };
@@ -331,7 +333,7 @@ export const setGeneralTurns = async (
} }
} }
await persistGeneralTurns(db, generalId, turns); await persistGeneralTurns(db, generalId, turns);
return { revision, turns: serializeTurnList(turns) }; return { revision, turns: serializeTurnList(turns), autorunLimit: await loadGeneralAutorunLimit(db, generalId) };
}; };
export const setGeneralTurn = async ( export const setGeneralTurn = async (
@@ -357,7 +359,7 @@ export const shiftGeneralTurns = async (
const turns = await loadGeneralTurns(db, generalId); const turns = await loadGeneralTurns(db, generalId);
const shifted = applyShift(turns, amount); const shifted = applyShift(turns, amount);
await persistGeneralTurns(db, generalId, shifted); await persistGeneralTurns(db, generalId, shifted);
return { revision, turns: serializeTurnList(shifted) }; return { revision, turns: serializeTurnList(shifted), autorunLimit: await loadGeneralAutorunLimit(db, generalId) };
}; };
export const repeatGeneralTurns = async ( export const repeatGeneralTurns = async (
@@ -372,7 +374,7 @@ export const repeatGeneralTurns = async (
const revision = await claimGeneralRevision(db, generalId, expectedRevision); const revision = await claimGeneralRevision(db, generalId, expectedRevision);
const turns = applyRepeat(await loadGeneralTurns(db, generalId), amount); const turns = applyRepeat(await loadGeneralTurns(db, generalId), amount);
await persistGeneralTurns(db, generalId, turns); await persistGeneralTurns(db, generalId, turns);
return { revision, turns: serializeTurnList(turns) }; return { revision, turns: serializeTurnList(turns), autorunLimit: await loadGeneralAutorunLimit(db, generalId) };
}; };
export const setNationTurns = async ( export const setNationTurns = async (
@@ -396,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,
@@ -407,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,
+23 -2
View File
@@ -117,7 +117,7 @@ const buildBattleRequest = () => ({
conflict: '{}', conflict: '{}',
}, },
attackerNation: { attackerNation: {
type: 'test', type: 'che_도적',
tech: 1000, tech: 1000,
level: 1, level: 1,
capital: 1, capital: 1,
@@ -193,7 +193,7 @@ const buildBattleRequest = () => ({
conflict: '{}', conflict: '{}',
}, },
defenderNation: { defenderNation: {
type: 'test', type: 'che_도적',
tech: 1000, tech: 1000,
level: 1, level: 1,
capital: 2, capital: 2,
@@ -309,6 +309,27 @@ describe('battle router orchestration', () => {
expect(battleSim.simulateCalls).toBe(0); expect(battleSim.simulateCalls).toBe(0);
}); });
it('rejects the neutral storage nation type before preparing or queuing a simulation', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
const request = buildBattleRequest();
request.attackerNation.type = 'che_중립';
await expect(caller.battle.prepareSimulation(request)).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await expect(caller.battle.simulate(request)).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(battleSim.simulateCalls).toBe(0);
});
it('returns queued then completed results via transport', async () => { it('returns queued then completed results via transport', async () => {
const battleSim = new QueuedBattleSimTransport(); const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = { const state: WorldStateRow = {
+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]);
});
});
+97 -5
View File
@@ -10,13 +10,15 @@ import {
setGeneralTurn, setGeneralTurn,
setGeneralTurns, setGeneralTurns,
setNationTurn, setNationTurn,
setNationTurnAtCurrentPosition,
setNationTurns, setNationTurns,
setNationTurnsAtCurrentPositions,
shiftGeneralTurns, shiftGeneralTurns,
shiftNationTurns, shiftNationTurns,
ReservedTurnRevisionConflictError, ReservedTurnRevisionConflictError,
} from '../src/turns/reservedTurns.js'; } from '../src/turns/reservedTurns.js';
const buildDb = () => { const buildDb = (autorunLimit: number | null = null) => {
const generalTurns = new Map<number, GeneralTurnRow[]>(); const generalTurns = new Map<number, GeneralTurnRow[]>();
const nationTurns = new Map<string, NationTurnRow[]>(); const nationTurns = new Map<string, NationTurnRow[]>();
const generalRevisions = new Map<number, number>(); const generalRevisions = new Map<number, number>();
@@ -45,7 +47,7 @@ const buildDb = () => {
findFirst: async () => null, findFirst: async () => null,
}, },
general: { general: {
findUnique: async () => null, findUnique: async () => ({ meta: autorunLimit === null ? {} : { autorun_limit: autorunLimit } }),
}, },
city: { city: {
findUnique: async () => null, findUnique: async () => null,
@@ -195,27 +197,30 @@ const buildDb = () => {
}, },
} as unknown as DatabaseClient; } as unknown as DatabaseClient;
return { db }; return { db, nationTurns, nationRevisions };
}; };
describe('reservedTurns', () => { describe('reservedTurns', () => {
it('sets and shifts general turns', async () => { it('sets and shifts general turns', async () => {
const { db } = buildDb(); const { db } = buildDb(2408);
const initial = await setGeneralTurn(db, 1, 0, 'che_화계', { destCityId: 10 }, 0); const initial = await setGeneralTurn(db, 1, 0, 'che_화계', { destCityId: 10 }, 0);
expect(initial.revision).toBe(1); expect(initial.revision).toBe(1);
expect(initial.turns).toHaveLength(MAX_GENERAL_TURNS); expect(initial.turns).toHaveLength(MAX_GENERAL_TURNS);
expect(initial.turns[0]?.action).toBe('che_화계'); expect(initial.turns[0]?.action).toBe('che_화계');
expect(initial.autorunLimit).toBe(2408);
const pushed = await shiftGeneralTurns(db, 1, 1, initial.revision); const pushed = await shiftGeneralTurns(db, 1, 1, initial.revision);
expect(pushed.revision).toBe(2); expect(pushed.revision).toBe(2);
expect(pushed.turns[0]?.action).toBe('휴식'); expect(pushed.turns[0]?.action).toBe('휴식');
expect(pushed.turns[1]?.action).toBe('che_화계'); expect(pushed.turns[1]?.action).toBe('che_화계');
expect(pushed.autorunLimit).toBe(2408);
const pulled = await shiftGeneralTurns(db, 1, -1, pushed.revision); const pulled = await shiftGeneralTurns(db, 1, -1, pushed.revision);
expect(pulled.turns[0]?.action).toBe('che_화계'); expect(pulled.turns[0]?.action).toBe('che_화계');
expect(pulled.turns[MAX_GENERAL_TURNS - 1]?.action).toBe('휴식'); expect(pulled.turns[MAX_GENERAL_TURNS - 1]?.action).toBe('휴식');
expect(pulled.autorunLimit).toBe(2408);
await expect(setGeneralTurn(db, 1, 2, 'che_훈련', {}, 1)).rejects.toBeInstanceOf( await expect(setGeneralTurn(db, 1, 2, 'che_훈련', {}, 1)).rejects.toBeInstanceOf(
ReservedTurnRevisionConflictError ReservedTurnRevisionConflictError
@@ -285,7 +290,7 @@ describe('reservedTurns', () => {
}); });
it('repeats the leading general pattern at the legacy interval', async () => { it('repeats the leading general pattern at the legacy interval', async () => {
const { db } = buildDb(); const { db } = buildDb(2408);
const seeded = await setGeneralTurns( const seeded = await setGeneralTurns(
db, db,
4, 4,
@@ -309,6 +314,7 @@ describe('reservedTurns', () => {
'che_사기진작', 'che_사기진작',
'che_징병', 'che_징병',
]); ]);
expect(repeated.autorunLimit).toBe(2408);
}); });
it('supports nation bulk/repeat and preserves the legacy amount-12 no-op', async () => { it('supports nation bulk/repeat and preserves the legacy amount-12 no-op', async () => {
@@ -339,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 () => ({}));
@@ -367,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();
});
}); });
+18 -2
View File
@@ -869,7 +869,7 @@ describe('appRouter', () => {
}); });
it('validates and persists general command arguments from the authenticated owner', async () => { it('validates and persists general command arguments from the authenticated owner', async () => {
const general = buildGeneralRow({ id: 13 }); const general = buildGeneralRow({ id: 13, meta: { autorun_limit: 2408 } });
const writes: unknown[] = []; const writes: unknown[] = [];
const changeJournal = new ChangeJournal(); const changeJournal = new ChangeJournal();
const caller = appRouter.createCaller( const caller = appRouter.createCaller(
@@ -885,6 +885,7 @@ describe('appRouter', () => {
}); });
expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } }); expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } });
expect(response.autorunLimit).toBe(2408);
expect(writes).toHaveLength(1); expect(writes).toHaveLength(1);
const written = writes[0] as { data: unknown[] }; const written = writes[0] as { data: unknown[] };
expect(written.data).toHaveLength(30); expect(written.data).toHaveLength(30);
@@ -1020,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 () => {
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { AVAILABLE_NATION_TRAIT_KEYS } from '@sammo-ts/logic';
import { loadBattleSimTraitOptions } from '../src/battleSim/simulatorOptions.js';
describe('selectable trait options', () => {
it('uses the Ref available nation-type list for founding and battle simulation inputs', async () => {
const options = await loadBattleSimTraitOptions();
expect(options.nationTypes.map((entry) => entry.key)).toEqual(AVAILABLE_NATION_TRAIT_KEYS);
expect(options.nationTypes).not.toEqual(
expect.arrayContaining([expect.objectContaining({ key: 'che_중립' })])
);
});
});
@@ -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' });
@@ -13,7 +13,10 @@ import type { ConstraintContext } from '@sammo-ts/logic';
import { GAME_TICKS_PER_TURN, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { GAME_TICKS_PER_TURN, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import { NATION_TRAIT_KEYS } from '@sammo-ts/logic/actionModules/traits/nation/index.js'; import {
AVAILABLE_NATION_TRAIT_KEYS,
isAvailableNationTraitKey,
} from '@sammo-ts/logic/actionModules/traits/nation/index.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import type { ReservedTurnEntry } from '../../reservedTurnStore.js'; import type { ReservedTurnEntry } from '../../reservedTurnStore.js';
@@ -348,8 +351,10 @@ export class GeneralAI {
chiefStatMin: this.scenarioConfig.stat.chiefMin, chiefStatMin: this.scenarioConfig.stat.chiefMin,
npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0), npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0),
availableNationTypes: Array.isArray(constValues.availableNationType) availableNationTypes: Array.isArray(constValues.availableNationType)
? constValues.availableNationType.filter((value) => typeof value === 'string') ? constValues.availableNationType.filter(
: NATION_TRAIT_KEYS.filter((value) => value !== 'che_중립'), (value): value is string => typeof value === 'string' && isAvailableNationTraitKey(value)
)
: [...AVAILABLE_NATION_TRAIT_KEYS],
}; };
const generalPolicy = new AutorunGeneralPolicy( const generalPolicy = new AutorunGeneralPolicy(
@@ -3,7 +3,7 @@ import {
LogCategory, LogCategory,
LogFormat, LogFormat,
LogScope, LogScope,
NATION_TRAIT_KEYS, AVAILABLE_NATION_TRAIT_KEYS,
getCityDistance, getCityDistance,
type City, type City,
type MapDefinition, type MapDefinition,
@@ -54,7 +54,6 @@ const NATION_COLORS = [
'#FFFFFF', '#FFFFFF',
'#A9A9A9', '#A9A9A9',
] as const; ] as const;
const AVAILABLE_NATION_TYPES = NATION_TRAIT_KEYS.filter((key) => key !== 'che_중립');
const NPC_TYPE = 6; const NPC_TYPE = 6;
const NPC_PREFIX = 'ⓤ'; const NPC_PREFIX = 'ⓤ';
const STAT_TYPE_WEIGHTS = { : 1, : 1 } as const; const STAT_TYPE_WEIGHTS = { : 1, : 1 } as const;
@@ -301,7 +300,7 @@ export const createRaiseNpcNationHandler = (options: {
const nationId = world.getNextNationId(); const nationId = world.getNextNationId();
const color = rng.choice([...NATION_COLORS]); const color = rng.choice([...NATION_COLORS]);
const typeCode = rng.choice([...AVAILABLE_NATION_TYPES]); const typeCode = rng.choice([...AVAILABLE_NATION_TRAIT_KEYS]);
const nation: Nation = { const nation: Nation = {
id: nationId, id: nationId,
name: `${NPC_PREFIX}${city.name}`, name: `${NPC_PREFIX}${city.name}`,
@@ -58,7 +58,7 @@ const simulatorOptions = {
{ id: 200, name: '궁병', armType: 2 }, { id: 200, name: '궁병', armType: 2 },
], ],
}, },
nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }], nationTypes: [{ key: 'che_도적', name: '도적', info: '금 수입 증가, 쌀 수입 감소' }],
eventDomesticTraits: [{ key: 'che_event_신산', name: '신산', info: '계략 강화' }], eventDomesticTraits: [{ key: 'che_event_신산', name: '신산', info: '계략 강화' }],
warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }], warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }],
personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }], personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }],
@@ -356,6 +356,10 @@ test('operates independent/game presets, imports my general, and renders battle
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await gotoSimulator(page); await gotoSimulator(page);
const nationTypeSelects = page.locator('[data-parity-id="attacker-nation"] select').first();
await expect(nationTypeSelects).toHaveValue('che_도적');
await expect(nationTypeSelects.locator('option[value="che_중립"]')).toHaveCount(0);
const notice = page.getByLabel('시뮬레이터 데이터 안내'); const notice = page.getByLabel('시뮬레이터 데이터 안내');
const noticeRect = await notice.boundingBox(); const noticeRect = await notice.boundingBox();
expect(noticeRect?.width).toBeLessThan(100); expect(noticeRect?.width).toBeLessThan(100);
+252 -15
View File
@@ -43,6 +43,7 @@ const inputOptions = {
cities: [ cities: [
{ value: 1, label: '업 (아국)' }, { value: 1, label: '업 (아국)' },
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' }, { value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
{ value: 3, label: '단양 (오)' },
], ],
nations: [ nations: [
{ value: 1, label: '아국', color: '#008000' }, { value: 1, label: '아국', color: '#008000' },
@@ -52,9 +53,21 @@ 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: '중립' }], nationTypes: [{ value: 'che_도적', label: '도적', description: '금 수입 증가, 쌀 수입 감소' }],
colors: [{ value: 0, label: '색상 1', color: '#ff0000' }], colors: [{ value: 0, label: '색상 1', color: '#ff0000' }],
items: { horse: [{ value: 'None', label: '판매/해제' }] }, items: { horse: [{ value: 'None', label: '판매/해제' }] },
recruitment: { recruitment: {
@@ -182,6 +195,27 @@ const commandTable = {
}, },
], ],
}, },
{
category: '군사',
values: [
{
key: 'che_출병',
name: '출병',
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
{
key: 'destCityId',
label: '대상 도시',
kind: 'select',
required: true,
optionSource: 'cities',
},
],
},
],
},
], ],
nation: [ nation: [
{ {
@@ -312,6 +346,7 @@ const generalContext = {
experience: 0, experience: 0,
dedication: 0, dedication: 0,
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' }, items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
turnTime: '2026-08-17T12:00:00.000Z',
}, },
city: { city: {
id: 1, id: 1,
@@ -366,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),
@@ -483,7 +518,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
if (name === 'turns.getCommandTable') return response(commandTableResponse); if (name === 'turns.getCommandTable') return response(commandTableResponse);
if (name === 'nation.getChiefCenter') return response(chiefCenter); if (name === 'nation.getChiefCenter') return response(chiefCenter);
if (name === 'turns.reserved.getGeneral') if (name === 'turns.reserved.getGeneral')
return response({ turns: generalTurns, revision: generalRevision }); return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 });
if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision }); if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision });
if (name === 'general.getRecentRecords') return response({ global: [], general: [], history: [] }); if (name === 'general.getRecentRecords') return response({ global: [], general: [], history: [] });
if (name === 'general.getFrontStatus') if (name === 'general.getFrontStatus')
@@ -520,7 +555,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
generalTurns[index] = { index, action: entry.action, args: entry.args ?? {} }; generalTurns[index] = { index, action: entry.action, args: entry.args ?? {} };
} }
generalRevision += 1; generalRevision += 1;
return response({ ok: true, revision: generalRevision, turns: generalTurns }); return response({ ok: true, revision: generalRevision, turns: generalTurns, autorunLimit: 2403 });
} }
if (name === 'turns.reserved.setNationBulk') { if (name === 'turns.reserved.setNationBulk') {
requests.push(body); requests.push(body);
@@ -561,12 +596,67 @@ test('renders and accepts every Ref strategy command at mobile width', async ({
await button.click(); await button.click();
const form = picker.getByTestId('command-argument-form'); const form = picker.getByTestId('command-argument-form');
await expect(form.getByTestId('command-argument-guidance')).toContainText(strategy.guidance); await expect(form.getByTestId('command-argument-guidance')).toContainText(strategy.guidance);
await expect(form.locator('select option')).toHaveCount(2); await expect(form.locator('select option')).toHaveCount(3);
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click(); await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
} }
await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') }); await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') });
}); });
test('defaults founding to a Ref-selectable nation trait without exposing the neutral storage trait', async ({
page,
}) => {
const foundingCommandTable = {
general: [
{
category: '국가',
values: [
{
key: 'che_건국',
name: '건국',
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
{ key: 'nationName', label: '국가명', kind: 'text', required: true, min: 1, max: 18 },
{
key: 'nationType',
label: '국가 성향',
kind: 'select',
required: true,
optionSource: 'nationTypes',
},
{
key: 'colorType',
label: '국기 색상',
kind: 'select',
required: true,
optionSource: 'colors',
},
],
},
],
},
],
nation: [],
inputOptions,
};
await install(page, false, foundingCommandTable);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '국가', exact: true }).click();
await picker.getByRole('button', { name: '건국', exact: true }).click();
const nationType = picker.getByLabel('국가 성향');
await expect(nationType).toHaveValue('che_도적');
await expect(nationType.locator('option[value="che_중립"]')).toHaveCount(0);
await expect(nationType.locator('option')).toHaveText(['도적']);
await nationType.focus();
await expect(nationType).toBeFocused();
await picker.screenshot({ path: test.info().outputPath('founding-selectable-nation-trait-desktop-1200.png') });
});
test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => { test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => {
const specialCommandTable = { const specialCommandTable = {
general: [ general: [
@@ -654,7 +744,7 @@ test('reserves force move, retirement, and resignation from the user command pic
await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.'); await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.');
await forceMoveForm.locator('select').selectOption('2'); await forceMoveForm.locator('select').selectOption('2');
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('강행'); await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】으로 강행');
const serialized = JSON.stringify(requests); const serialized = JSON.stringify(requests);
expect(serialized).toContain('"action":"che_은퇴","args":{}'); expect(serialized).toContain('"action":"che_은퇴","args":{}');
@@ -723,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 });
@@ -801,7 +977,9 @@ test('enters general and nation command arguments and sends exact values', async
return { width: rect.width, height: rect.height }; return { width: rect.width, height: rect.height };
}); });
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('화계'); await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
'【허창】에 화계실행'
);
await page.goto('/che/chief-center'); await page.goto('/che/chief-center');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
@@ -811,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();
@@ -824,12 +1008,14 @@ 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(
'【여포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);
@@ -906,7 +1092,9 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
await infantry.getByRole('button', { name: '절반', exact: true }).click(); await infantry.getByRole('button', { name: '절반', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') }); await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') });
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('징병'); await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
'【보병】 3500명 징병'
);
expect(JSON.stringify(requests)).toContain('"crewType":1100'); expect(JSON.stringify(requests)).toContain('"crewType":1100');
expect(JSON.stringify(requests)).toContain('"amount":3500'); expect(JSON.stringify(requests)).toContain('"amount":3500');
@@ -1112,6 +1300,53 @@ test('keeps the entered command visible and reports a server validation error',
await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2'); await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2');
}); });
test('keeps Ref command briefs and autonomous-action state after a turn mutation', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
const editor = page.locator('[data-command-scope="general"]');
const status = editor.locator('[data-command-autorun-status]');
const firstRow = editor.locator('.action-column > div').first();
await expect(status).toHaveText(/ : 200 3 · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/u);
await expect(firstRow).toContainText('휴식(자율 행동)');
expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '군사', exact: true }).click();
await picker.getByRole('button', { name: '출병', exact: true }).click();
await picker.getByTestId('command-argument-form').locator('select').selectOption('3');
await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(firstRow).toHaveText('【단양】으로 출병');
await expect(firstRow).toHaveAttribute('title', / /u);
expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)');
await expect(status).toHaveText(/ : 200 3 · .*/u);
const desktopGeometry = await editor.evaluate((element) => {
const statusElement = element.querySelector<HTMLElement>('[data-command-autorun-status]');
const row = element.querySelector<HTMLElement>('.action-column > div');
if (!statusElement || !row) throw new Error('autonomous command geometry is missing');
return {
horizontalOverflow: element.scrollWidth - element.clientWidth,
statusWidth: statusElement.getBoundingClientRect().width,
editorWidth: element.getBoundingClientRect().width,
rowHeight: row.getBoundingClientRect().height,
};
});
expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
expect(desktopGeometry.statusWidth).toBeLessThanOrEqual(desktopGeometry.editorWidth);
expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(20);
await page.screenshot({ path: test.info().outputPath('command-brief-autorun-desktop-1200.png'), fullPage: true });
await page.setViewportSize({ width: 500, height: 900 });
await expect(firstRow).toHaveText('【단양】으로 출병');
await expect(status).toBeVisible();
expect(await editor.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
await page.screenshot({ path: test.info().outputPath('command-brief-autorun-mobile-500.png'), fullPage: true });
});
test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => { test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => {
const requests = await install(page); const requests = await install(page);
await page.goto('/'); await page.goto('/');
@@ -1139,7 +1374,7 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
await blockedFire.click(); await blockedFire.click();
await picker.getByTestId('command-argument-form').locator('select').selectOption('2'); await picker.getByTestId('command-argument-form').locator('select').selectOption('2');
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('화계'); await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행');
await drag(0, 2); await drag(0, 2);
await editor.locator('details.selected-menu > summary').click(); await editor.locator('details.selected-menu > summary').click();
@@ -1150,7 +1385,7 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
await expect(editor.locator('.index-column > button.selected')).toHaveCount(15); await expect(editor.locator('.index-column > button.selected')).toHaveCount(15);
await editor.locator('details.selected-menu > summary').click(); await editor.locator('details.selected-menu > summary').click();
await editor.getByRole('button', { name: '붙여넣기', exact: true }).click(); await editor.getByRole('button', { name: '붙여넣기', exact: true }).click();
await expect(editor.locator('.action-column > div').nth(5)).toHaveText('화계'); await expect(editor.locator('.action-column > div').nth(5)).toHaveText('【허창】에 화계실행');
await drag(0, 2); await drag(0, 2);
page.once('dialog', (dialog) => dialog.accept('화계 세트')); page.once('dialog', (dialog) => dialog.accept('화계 세트'));
@@ -1229,7 +1464,9 @@ test('keeps the shared main and chief shell geometry and interaction states', as
await chiefArgumentForm.locator('input[type=number]').fill('300'); await chiefArgumentForm.locator('input[type=number]').fill('300');
await chiefArgumentForm.locator('select').selectOption('2'); await chiefArgumentForm.locator('select').selectOption('2');
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); await page.getByTestId('command-picker').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 포상'
);
expect(JSON.stringify(requests)).toContain('"action":"che_포상"'); expect(JSON.stringify(requests)).toContain('"action":"che_포상"');
expect(JSON.stringify(requests)).toContain('"destGeneralId":2'); expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({ const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({
+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);
@@ -827,6 +827,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await installFixture(page, state); await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page); await waitForMain(page);
if (artifactRoot) await mkdir(resolve(artifactRoot), { recursive: true });
await expect(page.locator('.main-global-menu')).toHaveCount(3); await expect(page.locator('.main-global-menu')).toHaveCount(3);
expect(await gridColumnCount(page, '.main-global-menu')).toBe(8); expect(await gridColumnCount(page, '.main-global-menu')).toBe(8);
@@ -2255,6 +2256,222 @@ test('nation menu presentation follows the server-derived permission matrix', as
); );
}); });
test('all main Lumen button families share the rounded pressed geometry', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
nationColor: '#663399',
};
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
const controls: Array<[string, Locator]> = [
[
'천통국 베팅',
page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'),
],
[
'게임정보',
page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', {
name: '게임정보',
exact: true,
}),
],
['회 의 실', page.locator('.layout-desktop [data-navigation-id="meeting"]')],
['기 밀 실', page.locator('.layout-desktop [data-navigation-id="secret-board"]')],
[
'당기기',
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '당기기' }),
],
[
'미루기',
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '미루기' }),
],
[
'펼치기',
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }),
],
['실시간 동기화', page.locator('.desktop-action-controls').getByRole('button', { name: / :/u })],
['갱 신', page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' })],
['로비로', page.locator('.desktop-action-controls').getByRole('button', { name: '로비로' })],
];
const measure = (control: Locator) =>
control.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderTop: style.borderTopWidth,
borderRight: style.borderRightWidth,
borderBottom: style.borderBottomWidth,
borderLeft: style.borderLeftWidth,
radius: style.borderRadius,
background: style.backgroundColor,
filter: style.filter,
};
});
const evidence: Record<string, Record<string, unknown>> = {};
for (const [index, [label, control]] of controls.entries()) {
await expect(control, `${label} control`).toBeVisible();
await expect(control).toHaveClass(/legacy-button/u);
await control.scrollIntoViewIfNeeded();
await page.mouse.move(1195, 895);
const base = await measure(control);
evidence[label] = { default: base };
if (artifactRoot) {
await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-default.png`) });
}
expect(base, `${label} default geometry`).toMatchObject({
marginTop: '0px',
borderTop: '0px',
borderRight: '1px',
borderBottom: '4px',
borderLeft: '1px',
radius: '5.25px',
filter: 'none',
});
await control.focus();
await expect(control, `${label} keyboard focus`).toBeFocused();
const focused = await measure(control);
evidence[label].focus = focused;
if (artifactRoot) {
await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-focus.png`) });
}
expect(focused.borderBottom, `${label} focus edge`).toBe('4px');
expect(focused.marginTop, `${label} focus position`).toBe('0px');
await control.hover();
const hovered = await measure(control);
evidence[label].hover = hovered;
if (artifactRoot) {
await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-hover.png`) });
}
expect(hovered.borderBottom, `${label} hover edge`).toBe('3px');
expect(hovered.marginTop, `${label} hover position`).toBe('1px');
expect(hovered.top, `${label} hover top`).toBeCloseTo(base.top + 1, 2);
expect(hovered.height, `${label} hover height`).toBeCloseTo(base.height - 1, 2);
expect(hovered.bottom, `${label} hover bottom`).toBeCloseTo(base.bottom, 2);
expect(hovered.background, `${label} hover face`).toBe(base.background);
const box = await control.boundingBox();
if (!box) throw new Error(`${label} control has no bounding box`);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
const pressed = await measure(control);
evidence[label].pointerDown = pressed;
if (artifactRoot) {
await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-pointer-down.png`) });
}
expect(pressed.borderBottom, `${label} pressed edge`).toBe('2px');
expect(pressed.marginTop, `${label} pressed position`).toBe('2px');
expect(pressed.top, `${label} pressed top`).toBeCloseTo(base.top + 2, 2);
expect(pressed.height, `${label} pressed height`).toBeCloseTo(base.height - 2, 2);
expect(pressed.bottom, `${label} pressed bottom`).toBeCloseTo(base.bottom, 2);
expect(pressed.background, `${label} pressed face`).toBe(base.background);
await page.mouse.move(1195, 895);
await page.mouse.up();
}
state.permission = 0;
await page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' }).click();
const disabledSecret = page.locator('.layout-desktop [data-navigation-id="secret-board"]');
await expect(disabledSecret).toHaveAttribute('aria-disabled', 'true');
await disabledSecret.scrollIntoViewIfNeeded();
const disabledBase = await measure(disabledSecret);
await disabledSecret.hover({ force: true });
const disabledHover = await measure(disabledSecret);
evidence['기 밀 실 disabled'] = { default: disabledBase, hover: disabledHover };
expect(disabledHover.borderBottom).toBe('4px');
expect(disabledHover.marginTop).toBe('0px');
expect(disabledHover.top).toBeCloseTo(disabledBase.top, 2);
if (artifactRoot) {
await disabledSecret.screenshot({ path: resolve(artifactRoot, 'disabled-secret-hover.png') });
await writeFile(
resolve(artifactRoot, 'main-lumen-button-states.json'),
`${JSON.stringify(evidence, null, 2)}\n`
);
}
await persistArtifact(page, `${basePath.slice(1)}-main-lumen-button-families`);
});
test('mobile main Lumen button families keep the same state geometry without overflow', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
nationColor: '#663399',
};
await installFixture(page, state);
await page.setViewportSize({ width: 500, height: 900 });
await waitForMain(page);
const controls = [
page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'),
page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', {
name: '게임정보',
exact: true,
}),
page.locator('.layout-mobile [data-navigation-id="meeting"]'),
page.locator('.layout-mobile [data-navigation-id="secret-board"]'),
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '당기기' }),
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '미루기' }),
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }),
page.locator('.desktop-action-controls').getByRole('button', { name: / :/u }),
page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' }),
page.locator('.desktop-action-controls').getByRole('button', { name: '로비로' }),
];
for (const control of controls) {
await expect(control).toBeVisible();
await expect(control).toHaveClass(/legacy-button/u);
await expect(control).toHaveCSS('border-radius', '5.25px');
await expect(control).toHaveCSS('border-bottom-width', '4px');
}
for (const control of [controls[0], controls[2], controls[4], controls[7]]) {
if (!control) throw new Error('mobile Lumen control is missing');
await control.scrollIntoViewIfNeeded();
await control.focus();
await expect(control).toBeFocused();
await expect(control).toHaveCSS('border-bottom-width', '4px');
await control.hover();
await expect(control).toHaveCSS('border-bottom-width', '3px');
await expect(control).toHaveCSS('margin-top', '1px');
const box = await control.boundingBox();
if (!box) throw new Error('mobile Lumen control is not measurable');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await expect(control).toHaveCSS('border-bottom-width', '2px');
await expect(control).toHaveCSS('margin-top', '2px');
await page.mouse.move(499, 899);
await page.mouse.up();
}
expect(
await page.evaluate(() => ({
document: document.documentElement.scrollWidth - document.documentElement.clientWidth,
body: document.body.scrollWidth - document.body.clientWidth,
}))
).toEqual({ document: 0, body: 0 });
await persistArtifact(page, `${basePath.slice(1)}-mobile-main-lumen-button-families`);
});
test('mobile single document refreshes once and preserves tokens on lobby return', async ({ page }) => { test('mobile single document refreshes once and preserves tokens on lobby return', async ({ page }) => {
const state: NavigationFixture = { const state: NavigationFixture = {
officerLevel: 5, officerLevel: 5,
@@ -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';
@@ -48,26 +48,6 @@
cursor: pointer; cursor: pointer;
} }
/*
* Ref renders the dashboard reload control with the Lumen navigation family:
* the bottom edge shortens on hover and again while pressed.
*/
.game-shell__action--navigation {
border-color: var(--sammo-button-navigation-border);
border-width: 0 1px 4px;
background: var(--sammo-button-navigation-bg);
}
.game-shell__action--navigation:not(:disabled):hover {
margin-top: 1px;
border-bottom-width: 3px;
}
.game-shell__action--navigation:not(:disabled):active {
margin-top: 2px;
border-bottom-width: 2px;
}
.game-feedback--error { .game-feedback--error {
color: var(--sammo-color-error); color: var(--sammo-color-error);
font-size: 0.85rem; font-size: 0.85rem;
@@ -56,7 +56,6 @@
--legacy-button-bg: var(--sammo-button-primary-bg); --legacy-button-bg: var(--sammo-button-primary-bg);
--legacy-button-border: var(--sammo-button-primary-border); --legacy-button-border: var(--sammo-button-primary-border);
--legacy-button-color: #fff; --legacy-button-color: #fff;
min-height: 35.5px;
margin-top: 0; margin-top: 0;
border-color: var(--legacy-button-border); border-color: var(--legacy-button-border);
border-style: solid; border-style: solid;
@@ -65,6 +64,7 @@
padding: 5.25px 10.5px; padding: 5.25px 10.5px;
background: var(--legacy-button-bg); background: var(--legacy-button-bg);
color: var(--legacy-button-color); color: var(--legacy-button-color);
filter: none;
line-height: 21px; line-height: 21px;
/* Ref's framework baseline for these controls. */ /* Ref's framework baseline for these controls. */
vertical-align: middle; vertical-align: middle;
@@ -21,6 +21,7 @@ import type {
CommandTable, CommandTable,
ReservedCommandRow, ReservedCommandRow,
} from './types'; } from './types';
import { formatReservedCommandBrief } from './reservedCommandBrief';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -37,6 +38,7 @@ const props = withDefaults(
currentTime?: string; currentTime?: string;
mapData?: CommandMapData | null; mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null; mapLayout?: CommandMapLayout | null;
autonomousUntil?: string | null;
}>(), }>(),
{ {
maxPushTurn: 6, maxPushTurn: 6,
@@ -47,6 +49,7 @@ const props = withDefaults(
currentTime: '--:--:--', currentTime: '--:--:--',
mapData: null, mapData: null,
mapLayout: null, mapLayout: null,
autonomousUntil: null,
} }
); );
@@ -70,6 +73,7 @@ const commandArgsValid = ref(false);
const expanded = ref(false); const expanded = ref(false);
const menuRevision = ref(0); const menuRevision = ref(0);
const pendingReservation = ref<CommandPatternEntry | null>(null); const pendingReservation = ref<CommandPatternEntry | null>(null);
const editorElement = ref<HTMLElement | null>(null);
const pickerElement = ref<HTMLElement | null>(null); const pickerElement = ref<HTMLElement | null>(null);
const collapsedRowCount = 15; const collapsedRowCount = 15;
@@ -133,12 +137,16 @@ const labelMap = computed(() => {
const displayRows = computed(() => const displayRows = computed(() =>
props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount) props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount)
); );
const quickPickerTop = computed(() => `${70 + (quickTarget.value ?? 0) * 34.4}px`); const quickPickerTop = ref('38px');
const isRecruitmentCommand = computed( const isRecruitmentCommand = computed(
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병' () => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
); );
const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value); const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value);
const rowLabel = (row: ReservedCommandRow): string => row.label ?? labelMap.value.get(row.action) ?? row.action; const rowLabel = (row: ReservedCommandRow): string =>
formatReservedCommandBrief(props.scope, row.action, row.args, props.commandTable) ||
row.label ||
labelMap.value.get(row.action) ||
row.action;
const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length); const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length);
const pattern = () => extractPattern(props.rows, selectedIndices()); const pattern = () => extractPattern(props.rows, selectedIndices());
const touchMenus = () => (menuRevision.value += 1); const touchMenus = () => (menuRevision.value += 1);
@@ -163,6 +171,19 @@ const finishDrag = (next: Set<number>) => {
}; };
const openPicker = (turnIndex?: number) => { const openPicker = (turnIndex?: number) => {
if (!props.compact && editorElement.value) {
const editorRect = editorElement.value.getBoundingClientRect();
const anchor =
turnIndex === undefined
? editorElement.value.querySelector<HTMLElement>('.control-pad')
: editorElement.value.querySelector<HTMLElement>(`[data-turn-index="${turnIndex}"]`);
if (anchor) {
const anchorRect = anchor.getBoundingClientRect();
quickPickerTop.value = `${
turnIndex === undefined ? anchorRect.bottom - editorRect.top : anchorRect.top - editorRect.top + 30
}px`;
}
}
quickTarget.value = turnIndex ?? null; quickTarget.value = turnIndex ?? null;
pickerOpen.value = true; pickerOpen.value = true;
selectedCommand.value = null; selectedCommand.value = null;
@@ -281,7 +302,7 @@ const rearrange = (direction: 'pull' | 'push') => {
const textCopy = async () => { const textCopy = async () => {
const lines = selectedIndices().map((index) => { const lines = selectedIndices().map((index) => {
const row = props.rows[index]; const row = props.rows[index];
return `${index + 1}${row?.label ?? labelMap.value.get(row?.action ?? '') ?? row?.action ?? ''}`; return `${index + 1}${row ? rowLabel(row) : ''}`;
}); });
await navigator.clipboard.writeText(lines.join('\n')); await navigator.clipboard.writeText(lines.join('\n'));
releaseSelection(); releaseSelection();
@@ -310,6 +331,7 @@ const clickOutsideMenu = (event: Event) => {
<template> <template>
<article <article
ref="editorElement"
class="reserved-command-editor" class="reserved-command-editor"
:class="{ :class="{
compact: props.compact, compact: props.compact,
@@ -326,6 +348,10 @@ const clickOutsideMenu = (event: Event) => {
<span>{{ props.title }} :</span><strong>{{ props.name ?? '-' }}</strong> <span>{{ props.title }} :</span><strong>{{ props.name ?? '-' }}</strong>
</header> </header>
<div v-if="props.autonomousUntil" class="autorun-status" data-command-autorun-status role="status">
자율 행동: {{ props.autonomousUntil }}
</div>
<div class="editor-layout"> <div class="editor-layout">
<aside class="control-pad"> <aside class="control-pad">
<div v-if="props.mobile && props.compact" class="mobile-identity legacy-bg1"> <div v-if="props.mobile && props.compact" class="mobile-identity legacy-bg1">
@@ -614,7 +640,11 @@ const clickOutsideMenu = (event: Event) => {
<div <div
v-for="row in displayRows" v-for="row in displayRows"
:key="row.index" :key="row.index"
:title="row.autonomous ? `${rowLabel(row)} · 자율 행동` : rowLabel(row)" :title="
row.autonomous
? `${rowLabel(row)} · 자율 행동${props.autonomousUntil ? ` (${props.autonomousUntil})` : ''}`
: rowLabel(row)
"
:class="{ autonomous: row.autonomous }" :class="{ autonomous: row.autonomous }"
> >
<span>{{ rowLabel(row) }}</span> <span>{{ rowLabel(row) }}</span>
@@ -635,9 +665,15 @@ const clickOutsideMenu = (event: Event) => {
</div> </div>
<div v-if="!props.compact" class="bottom-actions"> <div v-if="!props.compact" class="bottom-actions">
<button type="button" @click="emit('shift', -1)">당기기</button> <button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', -1)">
<button type="button" @click="emit('shift', 1)">미루기</button> 당기기
<button type="button" @click="expanded = !expanded">{{ expanded ? '접기' : '펼치기' }}</button> </button>
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', 1)">
미루기
</button>
<button class="legacy-button legacy-button--secondary" type="button" @click="expanded = !expanded">
{{ expanded ? '접기' : '펼치기' }}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -649,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 || quickTarget === null || 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="
@@ -760,7 +798,6 @@ const clickOutsideMenu = (event: Event) => {
.control-pad > button, .control-pad > button,
.clock, .clock,
.legacy-menu > summary, .legacy-menu > summary,
.bottom-actions button,
.select-command { .select-command {
box-sizing: border-box; box-sizing: border-box;
min-height: 34px; min-height: 34px;
@@ -850,6 +887,15 @@ const clickOutsideMenu = (event: Event) => {
display: grid; display: grid;
grid-template-columns: 75px 40px minmax(0, 1fr) 38px; grid-template-columns: 75px 40px minmax(0, 1fr) 38px;
} }
.autorun-status {
padding: 3px 6px;
border-bottom: 1px solid #2d6574;
background: #102c35;
color: #aaffff;
font-size: 0.78rem;
line-height: 1.35;
text-align: center;
}
.queue-grid.advanced { .queue-grid.advanced {
grid-template-columns: 34px 75px 40px minmax(0, 1fr); grid-template-columns: 34px 75px 40px minmax(0, 1fr);
} }
@@ -1102,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;
@@ -1115,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 {
@@ -1145,9 +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>
@@ -0,0 +1,188 @@
import { JosaUtil } from '@sammo-ts/common';
import type { CommandOption, CommandTable } from './types';
type CommandScope = 'general' | 'nation';
type CommandArgs = Record<string, unknown>;
const CITY_TO_COMMANDS = new Set(['che_이동', 'che_강행', 'che_출병', 'che_천도']);
const CITY_AT_COMMANDS = new Set([
'che_첩보',
'che_화계',
'che_선동',
'che_탈취',
'che_파괴',
'che_백성동원',
'che_허보',
]);
const CITY_OBJECT_COMMANDS = new Set(['che_수몰', 'che_초토화']);
const NATION_AT_COMMANDS = new Set(['che_선전포고', 'che_급습', 'che_이호경식']);
const NATION_PROPOSAL_COMMANDS = new Set(['che_불가침파기제의', 'che_종전제의']);
const ITEM_TYPE_NAMES: Record<string, string> = {
horse: '명마',
weapon: '무기',
book: '서적',
item: '도구',
};
const asArgs = (value: unknown): CommandArgs =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as CommandArgs) : {};
const firstValue = (args: CommandArgs, keys: readonly string[]): unknown => {
for (const key of keys) {
if (args[key] !== undefined) return args[key];
}
return undefined;
};
const optionLabel = (options: readonly CommandOption[], value: unknown): string | null => {
const option = options.find((entry) => entry.value === value || String(entry.value) === String(value));
if (!option) return null;
// 도시·장수 option에는 선택 보조용 소속 정보가 괄호로 붙지만 Ref brief에는 이름만 들어간다.
return option.label.replace(/\s+\([^)]*\)$/u, '');
};
const commandNameMap = (table: CommandTable | null, scope: CommandScope): Map<string, string> => {
const result = new Map<string, string>([['휴식', '휴식']]);
for (const group of table?.[scope] ?? []) {
for (const command of group.values) result.set(command.key, command.name);
}
return result;
};
const defaultCommandName = (action: string): string => action.replace(/^(?:che_|cr_|event_)/u, '');
const numberText = (value: unknown, grouped = false): string => {
if (typeof value !== 'number' || !Number.isFinite(value)) return String(value ?? '');
return grouped ? value.toLocaleString('en-US') : String(value);
};
const wrap = (value: string): string => `${value}`;
const withParticle = (value: string, particle: '을' | '으로'): string => `${value}${JosaUtil.pick(value, particle)}`;
const wrappedWithParticle = (value: string, particle: '을' | '으로'): string =>
`${wrap(value)}${JosaUtil.pick(value, particle)}`;
export const formatReservedCommandBrief = (
scope: CommandScope,
action: string,
rawArgs: unknown,
table: CommandTable | null
): string => {
const args = asArgs(rawArgs);
const input = table?.inputOptions;
const commandNames = commandNameMap(table, scope);
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?.generalTargets?.[action] ?? input?.generals ?? [],
firstValue(args, ['destGeneralId', 'destGeneralID'])
);
if (CITY_TO_COMMANDS.has(action) && cityName) {
return `${wrappedWithParticle(cityName, '으로')} ${commandName}`;
}
if (action === 'cr_인구이동' && cityName) {
return `${wrappedWithParticle(cityName, '으로')} ${numberText(args.amount, true)}${commandName}`;
}
if (CITY_AT_COMMANDS.has(action) && cityName) {
const suffix =
scope === 'nation' ? commandName : action === 'che_첩보' ? `${commandName} 실행` : `${commandName}실행`;
return `${wrap(cityName)}${suffix}`;
}
if (CITY_OBJECT_COMMANDS.has(action) && cityName) {
return `${wrappedWithParticle(cityName, '을')} ${commandName}`;
}
if (action === 'che_임관' && nationName) {
return `${wrappedWithParticle(nationName, '으로')} ${commandName}`;
}
if (NATION_AT_COMMANDS.has(action) && nationName) {
return `${wrap(nationName)}${commandName}`;
}
if (NATION_PROPOSAL_COMMANDS.has(action) && nationName) {
return `${wrap(nationName)}에게 ${commandName}`;
}
if (action === 'che_불가침제의' && nationName) {
return `${wrap(nationName)}에게 ${numberText(args.year)}${numberText(args.month)}월까지 ${commandName}`;
}
if (action === 'che_등용' && generalName) {
return `${wrappedWithParticle(generalName, '을')} ${commandName}`;
}
if (action === 'che_장수대상임관' && generalName) {
return `${wrappedWithParticle(generalName, '을')} 따라 임관`;
}
if (action === 'che_선양' && generalName) {
return `${wrap(generalName)}에게 ${commandName}`;
}
if (action === 'che_랜덤임관') {
return '무작위 국가로 임관';
}
if ((action === 'che_건국' || action === 'cr_건국') && typeof args.nationName === 'string') {
return `${wrappedWithParticle(args.nationName, '을')} 건국`;
}
if (action === 'che_무작위건국' && typeof args.nationName === 'string') {
return `${wrappedWithParticle(args.nationName, '을')} 무작위 도시에 건국`;
}
if (action === 'che_군량매매') {
return `군량 ${numberText(args.amount)}${args.buyRice ? '구입' : '판매'}`;
}
if (action === 'che_헌납') {
return `${args.isGold ? '금' : '쌀'} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_증여' && generalName) {
return `${wrap(generalName)}에게 ${args.isGold ? '금' : '쌀'} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_징병' || action === 'che_모병') {
const crewType = optionLabel(input?.crewTypes ?? [], args.crewType);
if (crewType) return `${wrap(crewType)} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_숙련전환') {
const srcArmType = optionLabel(input?.armTypes ?? [], args.srcArmType);
const destArmType = optionLabel(input?.armTypes ?? [], args.destArmType);
if (srcArmType && destArmType) return `${wrap(srcArmType)}숙련을 ${wrap(destArmType)}숙련으로 전환`;
}
if (action === 'che_장비매매') {
const itemType = typeof args.itemType === 'string' ? args.itemType : '';
if (args.itemCode === 'None') {
const itemTypeName = ITEM_TYPE_NAMES[itemType];
if (itemTypeName) return `${withParticle(itemTypeName, '을')} 판매`;
}
const itemName = optionLabel(input?.items[itemType] ?? [], args.itemCode);
if (itemName) {
const itemRawName = itemName.replace(/\([^)]*\)$/u, '');
return `${wrap(itemName)}${JosaUtil.pick(itemRawName, '을')} 구입`;
}
}
if (action === 'che_발령' && generalName && cityName) {
return `${wrap(generalName)}${wrappedWithParticle(cityName, '으로')} ${commandName}`;
}
if (action === 'che_부대탈퇴지시' && generalName) {
return `${wrap(generalName)}${commandName}`;
}
if ((action === 'che_포상' || action === 'che_몰수') && generalName) {
return `${wrap(generalName)} ${args.isGold ? '금' : '쌀'} ${numberText(args.amount, true)} ${commandName}`;
}
if (action === 'che_물자원조' && nationName && Array.isArray(args.amountList)) {
return `${wrap(nationName)}에게 국고 ${numberText(args.amountList[0], true)} 병량 ${numberText(
args.amountList[1],
true
)} ${commandName}`;
}
if (action === 'che_증축' || action === 'che_감축') {
return `수도를 ${commandName}`;
}
if (action === 'che_국기변경') {
return '【국기】를 변경';
}
if (action === 'che_국호변경' && typeof args.nationName === 'string') {
return `국호를 ${wrappedWithParticle(args.nationName, '으로')} 변경`;
}
if (action === 'che_피장파장' && nationName) {
const targetAction = typeof args.commandType === 'string' ? args.commandType : '';
const targetName = commandNames.get(targetAction) ?? defaultCommandName(targetAction);
return `${wrap(nationName)}${wrap(targetName)} ${commandName}`;
}
return 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 ?? '')] ?? [];
} }
@@ -2,7 +2,7 @@
import { computed, onUnmounted, ref, watch } from 'vue'; import { computed, onUnmounted, ref, watch } from 'vue';
import { addMinutes } from 'date-fns'; import { addMinutes } from 'date-fns';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue'; import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime'; import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime';
import type { import type {
CommandMapData, CommandMapData,
CommandMapLayout, CommandMapLayout,
@@ -65,6 +65,26 @@ const rows = computed<ReservedCommandRow[]>(() => {
}); });
}); });
const autonomousUntil = computed(() => {
if (props.autorunLimit == null) return null;
const baseYear = props.currentYear ?? 0;
const baseMonth = props.currentMonth ?? 1;
const currentAbsoluteMonth = baseYear * 12 + baseMonth - 1;
const lastAutonomousMonth = props.autorunLimit - 1;
if (lastAutonomousMonth < currentAbsoluteMonth) return null;
const untilYear = Math.floor(lastAutonomousMonth / 12);
const untilMonth = (lastAutonomousMonth % 12) + 1;
const base = props.general?.turnTime ? new Date(props.general.turnTime) : null;
const term = props.turnTermMinutes ?? 0;
const expiresAt =
base && Number.isFinite(base.getTime())
? addMinutes(base, (lastAutonomousMonth - currentAbsoluteMonth) * term)
: null;
const currentTimeLabel = expiresAt ? formatLocalDateTime(expiresAt) : '현재시각 확인 불가';
return `${untilYear}${untilMonth}月 · ${currentTimeLabel}까지`;
});
const currentServerTime = ref('--:--:--'); const currentServerTime = ref('--:--:--');
let sampledServerTimeMs: number | null = null; let sampledServerTimeMs: number | null = null;
let sampledClientTimeMs = 0; let sampledClientTimeMs = 0;
@@ -112,6 +132,7 @@ onUnmounted(() => {
:current-time="currentServerTime" :current-time="currentServerTime"
:map-data="props.mapData" :map-data="props.mapData"
:map-layout="props.mapLayout" :map-layout="props.mapLayout"
:autonomous-until="autonomousUntil"
@reserve-bulk="emit('set-general-turns', $event)" @reserve-bulk="emit('set-general-turns', $event)"
@shift="emit('shift-general-turns', $event)" @shift="emit('shift-general-turns', $event)"
@repeat="emit('repeat-general-turns', $event)" @repeat="emit('repeat-general-turns', $event)"
@@ -26,10 +26,11 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:link="entry" :link="entry"
:enabled="isNavigationConfigured(entry)" :enabled="isNavigationConfigured(entry)"
:active="isActive(entry)" :active="isActive(entry)"
lumen-variant="navigation"
/> />
<div v-else-if="entry.kind === 'group'" class="main-menu-popup"> <div v-else-if="entry.kind === 'group'" class="main-menu-popup">
<button <button
class="main-menu-button" class="main-menu-button legacy-button legacy-button--navigation"
type="button" type="button"
:data-menu-id="entry.id" :data-menu-id="entry.id"
:aria-expanded="openId === entry.id" :aria-expanded="openId === entry.id"
@@ -63,9 +64,10 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:link="entry.main" :link="entry.main"
:enabled="isNavigationConfigured(entry.main)" :enabled="isNavigationConfigured(entry.main)"
:active="isActive(entry.main)" :active="isActive(entry.main)"
lumen-variant="navigation"
/> />
<button <button
class="main-menu-button main-menu-split__toggle" class="main-menu-button main-menu-split__toggle legacy-button legacy-button--navigation"
type="button" type="button"
:data-menu-id="entry.id" :data-menu-id="entry.id"
:aria-label="`${entry.main.label} 하위 메뉴`" :aria-label="`${entry.main.label} 하위 메뉴`"
@@ -115,33 +117,6 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
min-width: 0; min-width: 0;
} }
.main-global-menu > :deep(.main-menu-link),
.main-menu-popup > .main-menu-button,
.main-menu-split > :deep(.main-menu-link),
.main-menu-split > .main-menu-split__toggle {
border-color: var(--sammo-button-navigation-border);
background-color: var(--sammo-button-navigation-bg);
background-image: none;
}
.main-global-menu > :deep(.main-menu-link:hover),
.main-global-menu > :deep(.main-menu-link:focus-visible),
.main-global-menu > :deep(.main-menu-link:active),
.main-menu-popup > .main-menu-button:hover,
.main-menu-popup > .main-menu-button:focus-visible,
.main-menu-popup > .main-menu-button:active,
.main-menu-popup > .main-menu-button[aria-expanded='true'],
.main-menu-split > :deep(.main-menu-link:hover),
.main-menu-split > :deep(.main-menu-link:focus-visible),
.main-menu-split > :deep(.main-menu-link:active),
.main-menu-split > .main-menu-split__toggle:hover,
.main-menu-split > .main-menu-split__toggle:focus-visible,
.main-menu-split > .main-menu-split__toggle:active,
.main-menu-split > .main-menu-split__toggle[aria-expanded='true'] {
border-color: var(--sammo-button-navigation-border);
background-color: var(--sammo-button-navigation-bg);
}
.main-menu-popup > .main-menu-button, .main-menu-popup > .main-menu-button,
.main-menu-split > :deep(.main-menu-link) { .main-menu-split > :deep(.main-menu-link) {
width: 100%; width: 100%;
@@ -157,7 +132,11 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
width: 28px; width: 28px;
padding: 0; padding: 0;
border-left-width: 0; border-left-width: 0;
border-radius: 0 3px 3px 0; border-radius: 0 5.25px 5.25px 0;
}
.main-menu-split > :deep(.main-menu-link) {
border-radius: 5.25px 0 0 5.25px;
} }
.menu-caret { .menu-caret {
@@ -33,15 +33,17 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
:link="entry" :link="entry"
:enabled="isNationNavigationEnabled(entry, access)" :enabled="isNationNavigationEnabled(entry, access)"
:active="isActive(entry)" :active="isActive(entry)"
lumen-variant="lumen"
/> />
<div v-else-if="entry.kind === 'split'" class="nation-menu-split"> <div v-else-if="entry.kind === 'split'" class="nation-menu-split">
<MainNavigationLink <MainNavigationLink
:link="entry.main" :link="entry.main"
:enabled="isNationNavigationEnabled(entry.main, access)" :enabled="isNationNavigationEnabled(entry.main, access)"
:active="isActive(entry.main)" :active="isActive(entry.main)"
lumen-variant="lumen"
/> />
<button <button
class="main-menu-button nation-menu-split__toggle" class="main-menu-button nation-menu-split__toggle legacy-button legacy-button--lumen"
type="button" type="button"
:data-menu-id="entry.id" :data-menu-id="entry.id"
:aria-label="`${entry.main.label} 하위 메뉴`" :aria-label="`${entry.main.label} 하위 메뉴`"
@@ -81,13 +83,14 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
.main-nation-menu :deep(.main-menu-link), .main-nation-menu :deep(.main-menu-link),
.main-nation-menu .main-menu-button { .main-nation-menu .main-menu-button {
border-color: color-mix(in srgb, var(--nation-menu-color) 85%, #000); --legacy-button-bg: var(--nation-menu-color);
background-color: var(--nation-menu-color); --legacy-button-border: color-mix(in srgb, var(--nation-menu-color) 90%, #000);
--legacy-button-color: #fff;
background-image: none; background-image: none;
} }
.main-nation-menu.dark-label :deep(.main-menu-link), .main-nation-menu.dark-label :deep(.main-menu-link),
.main-nation-menu.dark-label .main-menu-button { .main-nation-menu.dark-label .main-menu-button {
color: #000; --legacy-button-color: #000;
} }
.nation-menu-split { .nation-menu-split {
@@ -106,7 +109,11 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
width: 28px; width: 28px;
padding: 0; padding: 0;
border-left-width: 0; border-left-width: 0;
border-radius: 0 3px 3px 0; border-radius: 0 5.25px 5.25px 0;
}
.nation-menu-split > :deep(.main-menu-link) {
border-radius: 5.25px 0 0 5.25px;
} }
.menu-caret { .menu-caret {
@@ -8,11 +8,13 @@ const props = withDefaults(
enabled?: boolean; enabled?: boolean;
compact?: boolean; compact?: boolean;
active?: boolean; active?: boolean;
lumenVariant?: 'navigation' | 'lumen';
}>(), }>(),
{ {
enabled: true, enabled: true,
compact: false, compact: false,
active: false, active: false,
lumenVariant: undefined,
} }
); );
@@ -22,13 +24,16 @@ const emit = defineEmits<{
const label = computed(() => (props.compact ? (props.link.compactLabel ?? props.link.label) : props.link.label)); const label = computed(() => (props.compact ? (props.link.compactLabel ?? props.link.label) : props.link.label));
const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefined)); const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefined));
const lumenClasses = computed(() =>
props.lumenVariant ? ['legacy-button', `legacy-button--${props.lumenVariant}`] : []
);
</script> </script>
<template> <template>
<RouterLink <RouterLink
v-if="enabled && link.to" v-if="enabled && link.to"
class="main-menu-link" class="main-menu-link"
:class="{ highlight: active }" :class="[lumenClasses, { highlight: active }]"
:to="link.to" :to="link.to"
:target="link.newTab ? '_blank' : undefined" :target="link.newTab ? '_blank' : undefined"
:rel="rel" :rel="rel"
@@ -40,7 +45,7 @@ const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefine
<a <a
v-else-if="enabled && link.href" v-else-if="enabled && link.href"
class="main-menu-link" class="main-menu-link"
:class="{ highlight: active }" :class="[lumenClasses, { highlight: active }]"
:href="link.href" :href="link.href"
:target="link.newTab ? '_blank' : undefined" :target="link.newTab ? '_blank' : undefined"
:rel="rel" :rel="rel"
@@ -52,6 +57,7 @@ const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefine
<span <span
v-else v-else
class="main-menu-link disabled" class="main-menu-link disabled"
:class="lumenClasses"
role="link" role="link"
aria-disabled="true" aria-disabled="true"
:title="link.unavailableReason" :title="link.unavailableReason"
@@ -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%);
@@ -15,3 +15,12 @@ export const formatLocalTimeSeconds = (value: string | Date): string => {
.map((part) => String(part).padStart(2, '0')) .map((part) => String(part).padStart(2, '0'))
.join(':'); .join(':');
}; };
export const formatLocalDateTime = (value: string | Date): string => {
const parsed = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(parsed.getTime())) return '-';
const date = [parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate()]
.map((part, index) => String(part).padStart(index === 0 ? 4 : 2, '0'))
.join('-');
return `${date} ${formatLocalTimeSeconds(parsed)}`;
};
@@ -256,6 +256,11 @@ const toExportedGeneral = (general: GeneralDraft): GeneralExport => ({
inheritBuff: { ...general.inheritBuff }, inheritBuff: { ...general.inheritBuff },
}); });
const resolveAvailableNationType = (candidate?: string | null): string => {
const available = options.value?.nationTypes ?? [];
return available.some((entry) => entry.key === candidate) ? (candidate as string) : (available[0]?.key ?? '');
};
const initializeDefaults = async () => { const initializeDefaults = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
@@ -269,9 +274,9 @@ const initializeDefaults = async () => {
repeatCnt.value = 1; repeatCnt.value = 1;
seed.value = ''; seed.value = '';
const nationTypeDefault = context.nationTypes[0]?.key ?? 'che_중립'; const nationTypeDefault = resolveAvailableNationType(me?.nation?.typeCode);
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault; attackerNation.type = nationTypeDefault;
defenderNation.type = me?.nation?.typeCode ?? nationTypeDefault; defenderNation.type = nationTypeDefault;
attackerNation.level = me?.nation?.level ?? 0; attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = me?.nation?.level ?? 0; defenderNation.level = me?.nation?.level ?? 0;
@@ -304,10 +309,10 @@ const applyGameEnvironment = () => {
return; return;
} }
const me = gameDefaults.value; const me = gameDefaults.value;
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립'; const nationTypeDefault = resolveAvailableNationType(me?.nation?.typeCode);
year.value = options.value.world.currentYear; year.value = options.value.world.currentYear;
month.value = options.value.world.currentMonth; month.value = options.value.world.currentMonth;
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault; attackerNation.type = nationTypeDefault;
defenderNation.type = attackerNation.type; defenderNation.type = attackerNation.type;
attackerNation.level = me?.nation?.level ?? 0; attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = attackerNation.level; defenderNation.level = attackerNation.level;
@@ -326,7 +331,7 @@ const applyIndependentEnvironment = () => {
if (!options.value) { if (!options.value) {
return; return;
} }
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립'; const nationTypeDefault = resolveAvailableNationType();
year.value = options.value.world.startYear; year.value = options.value.world.startYear;
month.value = 1; month.value = 1;
seed.value = ''; seed.value = '';
@@ -740,13 +745,13 @@ const importBattle = (data: BattleExport) => {
month.value = data.month; month.value = data.month;
repeatCnt.value = data.repeatCnt; repeatCnt.value = data.repeatCnt;
attackerNation.type = data.attackerNation.type; attackerNation.type = resolveAvailableNationType(data.attackerNation.type);
attackerNation.level = data.attackerNation.level; attackerNation.level = data.attackerNation.level;
attackerNation.tech = Math.floor(data.attackerNation.tech / 1000); attackerNation.tech = Math.floor(data.attackerNation.tech / 1000);
attackerNation.isCapital = data.attackerNation.capital === 1; attackerNation.isCapital = data.attackerNation.capital === 1;
attackerCity.level = data.attackerCity.level; attackerCity.level = data.attackerCity.level;
defenderNation.type = data.defenderNation.type; defenderNation.type = resolveAvailableNationType(data.defenderNation.type);
defenderNation.level = data.defenderNation.level; defenderNation.level = data.defenderNation.level;
defenderNation.tech = Math.floor(data.defenderNation.tech / 1000); defenderNation.tech = Math.floor(data.defenderNation.tech / 1000);
defenderNation.isCapital = data.defenderNation.capital === 3; defenderNation.isCapital = data.defenderNation.capital === 3;
+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 {
+48 -10
View File
@@ -352,7 +352,15 @@ 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
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 <ChiefTurnCard
v-if="chief.name" v-if="chief.name"
:officer-level-text="chief.officerLevelText" :officer-level-text="chief.officerLevelText"
@@ -368,6 +376,10 @@ const repeatTurns = async (amount: number) => {
/> />
<div v-else class="empty-chief-slot" aria-hidden="true"></div> <div v-else class="empty-chief-slot" aria-hidden="true"></div>
</template> </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 {
+15 -33
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;
@@ -143,7 +144,7 @@ watch(
</h1> </h1>
<div class="game-shell__actions desktop-action-controls"> <div class="game-shell__actions desktop-action-controls">
<button <button
class="game-shell__action toggle" class="game-shell__action toggle legacy-button legacy-button--navigation"
:class="{ active: realtimeEnabled }" :class="{ active: realtimeEnabled }"
type="button" type="button"
@click="dashboard.setRealtimeEnabled(!realtimeEnabled)" @click="dashboard.setRealtimeEnabled(!realtimeEnabled)"
@@ -151,7 +152,7 @@ watch(
실시간 동기화: {{ realtimeLabel }} 실시간 동기화: {{ realtimeLabel }}
</button> </button>
<button <button
class="game-shell__action game-shell__action--navigation" class="game-shell__action legacy-button legacy-button--navigation"
type="button" type="button"
:disabled="refreshing" :disabled="refreshing"
:aria-busy="refreshing" :aria-busy="refreshing"
@@ -159,7 +160,13 @@ watch(
> >
</button> </button>
<button class="game-shell__action" type="button" @click="moveLobby">로비로</button> <button
class="game-shell__action legacy-button legacy-button--navigation"
type="button"
@click="moveLobby"
>
로비로
</button>
</div> </div>
</header> </header>
@@ -475,10 +482,6 @@ button {
background-image: var(--sammo-texture-walnut); background-image: var(--sammo-texture-walnut);
} }
.toggle.active {
background: rgba(201, 164, 90, 0.2);
}
.game-shell__action.highlight { .game-shell__action.highlight {
border-color: #f39c12; border-color: #f39c12;
background: #8a5b13; background: #8a5b13;
@@ -750,29 +753,8 @@ button {
margin-top: 31px; margin-top: 31px;
} }
/*
* Ref renders these dashboard controls with the Lumen navigation family: no top
* border, 1px sides and a 4px bottom edge that shortens on hover and press
* while the control moves down.
*/
.desktop-action-controls .game-shell__action { .desktop-action-controls .game-shell__action {
border-color: #004f28; font-weight: 400;
border-style: solid;
border-width: 0 1px 4px;
background: #006b36;
color: #fff;
}
.desktop-action-controls .game-shell__action:hover {
margin-top: 1px;
border-bottom-width: 3px;
background: #00582c;
}
.desktop-action-controls .game-shell__action:active {
margin-top: 2px;
border-bottom-width: 2px;
background: #005128;
} }
.placeholder { .placeholder {
@@ -793,8 +775,8 @@ button {
} }
.desktop-action-controls .game-shell__action { .desktop-action-controls .game-shell__action {
padding-right: 8px; padding-right: 4px;
padding-left: 8px; padding-left: 4px;
} }
.main-page { .main-page {
@@ -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;
} }
@@ -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, []);
});
});
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
formatLocalDateTime,
formatLocalTimeSeconds, formatLocalTimeSeconds,
formatSeoulDateTime, formatSeoulDateTime,
formatSeoulHourMinute, formatSeoulHourMinute,
@@ -29,3 +30,12 @@ void test('formats an ISO instant with the client local clock', () => {
assert.equal(formatLocalTimeSeconds(instant.toISOString()), expected); assert.equal(formatLocalTimeSeconds(instant.toISOString()), expected);
assert.equal(formatLocalTimeSeconds('invalid'), '-'); assert.equal(formatLocalTimeSeconds('invalid'), '-');
}); });
void test('formats an autorun expiry instant with the client local date and seconds', () => {
const instant = new Date('2026-08-13T00:07:06.713Z');
const expectedDate = [instant.getFullYear(), instant.getMonth() + 1, instant.getDate()]
.map((part, index) => String(part).padStart(index === 0 ? 4 : 2, '0'))
.join('-');
assert.equal(formatLocalDateTime(instant), `${expectedDate} ${formatLocalTimeSeconds(instant)}`);
assert.equal(formatLocalDateTime('invalid'), '-');
});
@@ -0,0 +1,184 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatReservedCommandBrief } from '../src/components/command/reservedCommandBrief.ts';
import type { CommandAvailability, CommandTable } from '../src/components/command/types.ts';
const command = (key: string, name: string): CommandAvailability => ({
key,
name,
reqArg: false,
status: 'available',
possible: true,
inputFields: [],
});
const generalNames: Array<[string, string]> = [
['휴식', '휴식'],
['che_이동', '이동'],
['che_강행', '강행'],
['che_출병', '출병'],
['che_첩보', '첩보'],
['che_화계', '화계'],
['che_선동', '선동'],
['che_탈취', '탈취'],
['che_파괴', '파괴'],
['che_임관', '임관'],
['che_등용', '등용'],
['che_장수대상임관', '장수 대상 임관'],
['che_선양', '선양'],
['che_랜덤임관', '랜덤 임관'],
['che_건국', '건국'],
['che_무작위건국', '무작위 건국'],
['che_군량매매', '군량 매매'],
['che_헌납', '헌납'],
['che_증여', '증여'],
['che_징병', '징병'],
['che_모병', '모병'],
['che_숙련전환', '숙련 전환'],
['che_장비매매', '장비 매매'],
];
const nationNames: Array<[string, string]> = [
['휴식', '휴식'],
['che_발령', '발령'],
['che_부대탈퇴지시', '부대 탈퇴 지시'],
['che_포상', '포상'],
['che_몰수', '몰수'],
['che_물자원조', '물자 원조'],
['che_불가침제의', '불가침 제의'],
['che_불가침파기제의', '불가침 파기 제의'],
['che_종전제의', '종전 제의'],
['che_선전포고', '선전포고'],
['che_급습', '급습'],
['che_이호경식', '이호경식'],
['che_천도', '천도'],
['che_백성동원', '백성 동원'],
['che_허보', '허보'],
['che_수몰', '수몰'],
['che_초토화', '초토화'],
['che_증축', '증축'],
['che_감축', '감축'],
['che_국기변경', '국기 변경'],
['che_국호변경', '국호 변경'],
['che_피장파장', '피장파장'],
['cr_인구이동', '인구이동'],
];
const table: CommandTable = {
general: [{ category: '전체', values: generalNames.map(([key, name]) => command(key, name)) }],
nation: [{ category: '전체', values: nationNames.map(([key, name]) => command(key, name)) }],
inputOptions: {
cities: [
{ value: 2, label: '단양 (오 · 회계)' },
{ value: 3, label: '업 (위)' },
],
nations: [
{ value: 2, label: '오' },
{ value: 3, label: '위' },
],
generals: [
{ value: 8, label: '손권 (오 · 단양)' },
{ value: 9, label: '조조 (위 · 업)' },
],
generalTargets: {
che_포상: [
{ value: 8, label: '손권 (오 · 단양)' },
{ value: 10, label: '여포NPC (오 · 단양)' },
],
},
crewTypes: [{ value: 1100, label: '보병' }],
armTypes: [
{ value: 0, label: '보병' },
{ value: 1, label: '궁병' },
],
nationTypes: [],
colors: [],
items: {
horse: [
{ value: 'None', label: '판매/해제' },
{ value: 'che_명마_01_노기', label: '노기(+1)' },
],
},
recruitment: null,
},
};
test('Ref getBrief를 상속하는 출병·계략·모병까지 실제 인자 요약으로 표시한다', () => {
assert.equal(formatReservedCommandBrief('general', 'che_출병', { destCityId: 2 }, table), '【단양】으로 출병');
assert.equal(formatReservedCommandBrief('general', 'che_화계', { destCityId: 3 }, table), '【업】에 화계실행');
assert.equal(formatReservedCommandBrief('general', 'che_선동', { destCityId: 2 }, table), '【단양】에 선동실행');
assert.equal(
formatReservedCommandBrief('general', 'che_모병', { crewType: 1100, amount: 2400 }, table),
'【보병】 2400명 모병'
);
});
test('개인·인사·국가 명령의 Ref brief 변형을 보존한다', () => {
const cases: Array<[string, Record<string, unknown>, string]> = [
['che_이동', { destCityId: 3 }, '【업】으로 이동'],
['che_강행', { destCityId: 2 }, '【단양】으로 강행'],
['che_첩보', { destCityId: 2 }, '【단양】에 첩보 실행'],
['che_임관', { destNationId: 2 }, '【오】로 임관'],
['che_등용', { destGeneralId: 9 }, '【조조】를 등용'],
['che_장수대상임관', { destGeneralId: 8 }, '【손권】을 따라 임관'],
['che_선양', { destGeneralId: 8 }, '【손권】에게 선양'],
['che_랜덤임관', {}, '무작위 국가로 임관'],
['che_건국', { nationName: '촉한' }, '【촉한】을 건국'],
['che_무작위건국', { nationName: '오' }, '【오】를 무작위 도시에 건국'],
['che_군량매매', { amount: 500, buyRice: true }, '군량 500을 구입'],
['che_헌납', { amount: 300, isGold: false }, '쌀 300을 헌납'],
['che_증여', { destGeneralId: 8, amount: 200, isGold: true }, '【손권】에게 금 200을 증여'],
['che_징병', { crewType: 1100, amount: 3200 }, '【보병】 3200명 징병'],
['che_숙련전환', { srcArmType: 0, destArmType: 1 }, '【보병】숙련을 【궁병】숙련으로 전환'],
['che_장비매매', { itemType: 'horse', itemCode: 'None' }, '명마를 판매'],
['che_장비매매', { itemType: 'horse', itemCode: 'che_명마_01_노기' }, '【노기(+1)】를 구입'],
];
for (const [action, args, expected] of cases) {
assert.equal(formatReservedCommandBrief('general', action, args, table), expected, action);
}
});
test('국가 명령의 도시·국가·장수·자원 인자를 Ref brief로 표시한다', () => {
const cases: Array<[string, Record<string, unknown>, string]> = [
['che_발령', { destGeneralId: 8, destCityId: 3 }, '【손권】【업】으로 발령'],
['che_부대탈퇴지시', { destGeneralId: 8 }, '【손권】부대 탈퇴 지시'],
['che_포상', { destGeneralId: 8, amount: 12000, isGold: true }, '【손권】 금 12,000 포상'],
['che_몰수', { destGeneralId: 9, amount: 3400, isGold: false }, '【조조】 쌀 3,400 몰수'],
[
'che_물자원조',
{ destNationId: 2, amountList: [12000, 34000] },
'【오】에게 국고 12,000 병량 34,000 물자 원조',
],
['che_불가침제의', { destNationId: 2, year: 190, month: 8 }, '【오】에게 190년 8월까지 불가침 제의'],
['che_불가침파기제의', { destNationId: 2 }, '【오】에게 불가침 파기 제의'],
['che_종전제의', { destNationId: 3 }, '【위】에게 종전 제의'],
['che_선전포고', { destNationId: 2 }, '【오】에 선전포고'],
['che_급습', { destNationId: 3 }, '【위】에 급습'],
['che_이호경식', { destNationId: 2 }, '【오】에 이호경식'],
['che_천도', { destCityId: 2 }, '【단양】으로 천도'],
['che_백성동원', { destCityId: 3 }, '【업】에 백성 동원'],
['che_허보', { destCityId: 2 }, '【단양】에 허보'],
['che_수몰', { destCityId: 2 }, '【단양】을 수몰'],
['che_초토화', { destCityId: 3 }, '【업】을 초토화'],
['che_증축', {}, '수도를 증축'],
['che_감축', {}, '수도를 감축'],
['che_국기변경', { colorType: 2 }, '【국기】를 변경'],
['che_국호변경', { nationName: '진' }, '국호를 【진】으로 변경'],
['che_피장파장', { destNationId: 2, commandType: 'che_선전포고' }, '【오】에 【선전포고】 피장파장'],
['cr_인구이동', { destCityId: 2, amount: 12000 }, '【단양】으로 12,000명 인구이동'],
];
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를 재정의하지 않은 명령은 실제 표시명을 유지한다', () => {
assert.equal(formatReservedCommandBrief('general', '휴식', {}, table), '휴식');
assert.equal(formatReservedCommandBrief('general', 'che_훈련', {}, table), '훈련');
assert.equal(formatReservedCommandBrief('nation', 'che_필사즉생', {}, table), '필사즉생');
});
+16
View File
@@ -56,6 +56,22 @@ control keeps its semantic color and uses the shared opacity/cursor state.
Hover and active use the Ref Lumen bottom-border movement rather than an Hover and active use the Ref Lumen bottom-border movement rather than an
unrelated brightness filter. unrelated brightness filter.
The shared family is opt-in at each rendered control; defining the primitive
does not connect an existing `.main-menu-link`, `.game-shell__action`, or
feature button automatically. `MainNavigationLink.vue` exposes
`lumenVariant="navigation|lumen"` so top-level global and nation links can opt
in while flat popup menu items stay outside the raised family. The main page's
global menu, nation menu, desktop synchronization/reload/lobby controls, and
reserved-turn pull/push/expand row all use the same primitive. When adding a
new main-page control, inventory every desktop/mobile render site instead of
validating one representative button.
The primitive does not set a fixed `min-height`: Ref's 35.5px default height is
the result of line-height, padding, and the 4px edge, so it naturally becomes
34.5px/33.5px while the 1px/2px top margin keeps the bottom coordinate fixed.
Only a fixed-height owner such as the mobile bottom bar supplies explicit
45px/44px/43px state compensation.
Only layout belongs in the SFC: width, grid column, fixed-height compensation, Only layout belongs in the SFC: width, grid column, fixed-height compensation,
margins required by the page, and breakpoint-specific placement. Color base margins required by the page, and breakpoint-specific placement. Color base
variables may be supplied by the owner for dynamic nation/scenario colors, but variables may be supplied by the owner for dynamic nation/scenario colors, but
+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:
@@ -19,6 +19,17 @@ export const NATION_TRAIT_KEYS = [
export type NationTraitKey = (typeof NATION_TRAIT_KEYS)[number]; export type NationTraitKey = (typeof NATION_TRAIT_KEYS)[number];
// Ref GameConst::$availableNationType excludes the neutral storage/default trait.
// Founding, NPC founding, and the battle simulator must only expose this list.
export type AvailableNationTraitKey = Exclude<NationTraitKey, 'che_중립'>;
export const AVAILABLE_NATION_TRAIT_KEYS: readonly AvailableNationTraitKey[] = NATION_TRAIT_KEYS.filter(
(key): key is Exclude<NationTraitKey, 'che_중립'> => key !== 'che_중립'
);
export const isAvailableNationTraitKey = (value: string): value is AvailableNationTraitKey =>
AVAILABLE_NATION_TRAIT_KEYS.includes(value as AvailableNationTraitKey);
export type NationTraitModule = TraitModule; export type NationTraitModule = TraitModule;
export type NationTraitImporter = () => Promise<TraitModuleExport>; export type NationTraitImporter = () => Promise<TraitModuleExport>;
@@ -1,4 +1,4 @@
import { NATION_TRAIT_KEYS } from '@sammo-ts/logic/actionModules/traits/nation/index.js'; import { isAvailableNationTraitKey } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js'; import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
import { z } from 'zod'; import { z } from 'zod';
@@ -38,14 +38,12 @@ export const NATION_COLORS = [
'#A9A9A9', '#A9A9A9',
] as const; ] as const;
const SELECTABLE_NATION_TYPES = new Set<string>(NATION_TRAIT_KEYS.filter((key) => key !== 'che_중립'));
export const FOUNDING_ARGS_SCHEMA = z.object({ export const FOUNDING_ARGS_SCHEMA = z.object({
nationName: z nationName: z
.string() .string()
.min(1) .min(1)
.refine((value) => getLegacyStringWidth(value) <= 18), .refine((value) => getLegacyStringWidth(value) <= 18),
nationType: z.string().refine((value) => SELECTABLE_NATION_TYPES.has(value)), nationType: z.string().refine(isAvailableNationTraitKey),
colorType: z colorType: z
.number() .number()
.int() .int()
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import {
AVAILABLE_NATION_TRAIT_KEYS,
isAvailableNationTraitKey,
NATION_TRAIT_KEYS,
} from '../src/actionModules/traits/nation/index.js';
describe('Ref-selectable nation traits', () => {
it('keeps the neutral storage trait valid internally but unavailable to user selection', () => {
expect(NATION_TRAIT_KEYS).toContain('che_중립');
expect(AVAILABLE_NATION_TRAIT_KEYS).toEqual([
'che_도적',
'che_명가',
'che_음양가',
'che_종횡가',
'che_불가',
'che_오두미도',
'che_태평도',
'che_도가',
'che_묵가',
'che_덕가',
'che_병가',
'che_유가',
'che_법가',
]);
expect(isAvailableNationTraitKey('che_중립')).toBe(false);
expect(isAvailableNationTraitKey('che_도적')).toBe(true);
});
});
@@ -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();
}