merge: 최신 main을 비용 기반 갱신 점수에 통합
This commit is contained in:
@@ -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]),
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -771,6 +771,7 @@ export const buildTurnCommandTable = async (options: {
|
|||||||
cities: [],
|
cities: [],
|
||||||
nations: [],
|
nations: [],
|
||||||
generals: [],
|
generals: [],
|
||||||
|
generalTargets: {},
|
||||||
crewTypes: [],
|
crewTypes: [],
|
||||||
armTypes: [],
|
armTypes: [],
|
||||||
nationTypes: [],
|
nationTypes: [],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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), '필사즉생');
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user