Merge remote-tracking branch 'origin/main' into feature/recruitment-command-parity-20260813
# Conflicts: # app/game-api/src/router/turns/index.ts # app/game-api/src/turns/commandInput.ts # app/game-frontend/e2e/commandArguments.spec.ts # app/game-frontend/src/components/command/ReservedCommandEditor.vue # app/game-frontend/src/components/command/types.ts # app/game-frontend/src/views/ChiefCenterView.vue
This commit is contained in:
@@ -137,7 +137,24 @@ export const tournamentRouter = router({
|
|||||||
store.getMatches(),
|
store.getMatches(),
|
||||||
store.getBettingEntries(),
|
store.getBettingEntries(),
|
||||||
]);
|
]);
|
||||||
return { state, participants, matches, betCount: bets.length };
|
const participantIds = [...new Set(participants.map((participant) => participant.id))];
|
||||||
|
const iconRows =
|
||||||
|
participantIds.length === 0
|
||||||
|
? []
|
||||||
|
: await ctx.db.general.findMany({
|
||||||
|
where: { id: { in: participantIds } },
|
||||||
|
select: { id: true, picture: true, imageServer: true },
|
||||||
|
});
|
||||||
|
const iconsByGeneralId = new Map(iconRows.map((general) => [general.id, general]));
|
||||||
|
const publicParticipants = participants.map((participant) => {
|
||||||
|
const icon = iconsByGeneralId.get(participant.id);
|
||||||
|
return {
|
||||||
|
...participant,
|
||||||
|
picture: icon?.picture ?? null,
|
||||||
|
imageServer: icon?.imageServer ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { state, participants: publicParticipants, matches, betCount: bets.length };
|
||||||
}),
|
}),
|
||||||
getRankings: authedProcedure.query(async ({ ctx }) => {
|
getRankings: authedProcedure.query(async ({ ctx }) => {
|
||||||
await getMyGeneral(ctx);
|
await getMyGeneral(ctx);
|
||||||
@@ -177,7 +194,16 @@ export const tournamentRouter = router({
|
|||||||
}
|
}
|
||||||
const generals = await ctx.db.general.findMany({
|
const generals = await ctx.db.general.findMany({
|
||||||
where: { id: { in: [...rankMap.keys()] } },
|
where: { id: { in: [...rankMap.keys()] } },
|
||||||
select: { id: true, name: true, npcState: true, leadership: true, strength: true, intel: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
npcState: true,
|
||||||
|
picture: true,
|
||||||
|
imageServer: true,
|
||||||
|
leadership: true,
|
||||||
|
strength: true,
|
||||||
|
intel: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return tournamentRankTypes.map((prefix) => {
|
return tournamentRankTypes.map((prefix) => {
|
||||||
@@ -201,6 +227,8 @@ export const tournamentRouter = router({
|
|||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
name: general.name,
|
name: general.name,
|
||||||
npcState: general.npcState,
|
npcState: general.npcState,
|
||||||
|
picture: general.picture,
|
||||||
|
imageServer: general.imageServer,
|
||||||
stat,
|
stat,
|
||||||
games: win + draw + lose,
|
games: win + draw + lose,
|
||||||
win,
|
win,
|
||||||
|
|||||||
@@ -102,6 +102,24 @@ const resolveMapName = (worldState: WorldStateRow, fallback: string): string =>
|
|||||||
return typeof mapName === 'string' && mapName.trim().length > 0 ? mapName : fallback;
|
return typeof mapName === 'string' && mapName.trim().length > 0 ? mapName : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const plainLegacyInfo = (value: string): string =>
|
||||||
|
value
|
||||||
|
.replace(/<br\s*\/?>/giu, ' · ')
|
||||||
|
.replace(/<[^>]+>/gu, '')
|
||||||
|
.replace(/\s+/gu, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const readGeneralMetaNumber = (meta: unknown, key: string): number | null => {
|
||||||
|
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null;
|
||||||
|
const value = (meta as Record<string, unknown>)[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const assertReservedTurnPermission = async (
|
const assertReservedTurnPermission = async (
|
||||||
worldState: WorldStateRow,
|
worldState: WorldStateRow,
|
||||||
general: GeneralRow,
|
general: GeneralRow,
|
||||||
@@ -183,7 +201,19 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
};
|
};
|
||||||
for (const item of moduleBundle.itemModules) {
|
for (const item of moduleBundle.itemModules) {
|
||||||
if (item.buyable) {
|
if (item.buyable) {
|
||||||
items[item.slot].push({ value: item.key, label: item.name });
|
const cost = item.cost ?? 0;
|
||||||
|
const currentSecurity = city?.security ?? 0;
|
||||||
|
const availability =
|
||||||
|
currentSecurity < item.reqSecu
|
||||||
|
? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요`
|
||||||
|
: general.gold < cost
|
||||||
|
? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요`
|
||||||
|
: '현재 구입 가능';
|
||||||
|
items[item.slot].push({
|
||||||
|
value: item.key,
|
||||||
|
label: item.name,
|
||||||
|
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const inputOptions: TurnCommandInputOptions = {
|
const inputOptions: TurnCommandInputOptions = {
|
||||||
@@ -205,11 +235,19 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
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 })),
|
||||||
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({
|
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => {
|
||||||
value: Number(value),
|
const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`);
|
||||||
label,
|
return {
|
||||||
|
value: Number(value),
|
||||||
|
label,
|
||||||
|
...(dexterity === null ? {} : { description: `현재 숙련 ${dexterity.toLocaleString()}` }),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
nationTypes: traits.nationTypes.map((entry) => ({
|
||||||
|
value: entry.key,
|
||||||
|
label: entry.name,
|
||||||
|
description: plainLegacyInfo(entry.info),
|
||||||
})),
|
})),
|
||||||
nationTypes: traits.nationTypes.map((entry) => ({ value: entry.key, label: entry.name })),
|
|
||||||
colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({
|
colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({
|
||||||
value: index,
|
value: index,
|
||||||
label: `색상 ${index + 1}`,
|
label: `색상 ${index + 1}`,
|
||||||
@@ -226,6 +264,12 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
unitSet: environment.unitSet,
|
unitSet: environment.unitSet,
|
||||||
generalActionModules: moduleBundle.general,
|
generalActionModules: moduleBundle.general,
|
||||||
}),
|
}),
|
||||||
|
context: {
|
||||||
|
actorGold: general.gold,
|
||||||
|
actorRice: general.rice,
|
||||||
|
...(city ? { citySecurity: city.security } : {}),
|
||||||
|
...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return buildTurnCommandTable({
|
return buildTurnCommandTable({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface TurnCommandOption {
|
|||||||
value: TurnCommandOptionValue;
|
value: TurnCommandOptionValue;
|
||||||
label: string;
|
label: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnCommandRecruitmentCrewType {
|
export interface TurnCommandRecruitmentCrewType {
|
||||||
@@ -50,14 +51,7 @@ export interface TurnCommandRecruitmentInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TurnCommandOptionSource =
|
export type TurnCommandOptionSource =
|
||||||
| 'cities'
|
'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
|
||||||
| 'nations'
|
|
||||||
| 'generals'
|
|
||||||
| 'crewTypes'
|
|
||||||
| 'armTypes'
|
|
||||||
| 'nationTypes'
|
|
||||||
| 'colors'
|
|
||||||
| 'items';
|
|
||||||
|
|
||||||
export interface TurnCommandInputField {
|
export interface TurnCommandInputField {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -83,14 +77,50 @@ export interface TurnCommandInputOptions {
|
|||||||
colors: TurnCommandOption[];
|
colors: TurnCommandOption[];
|
||||||
items: Record<string, TurnCommandOption[]>;
|
items: Record<string, TurnCommandOption[]>;
|
||||||
recruitment: TurnCommandRecruitmentInfo | null;
|
recruitment: TurnCommandRecruitmentInfo | null;
|
||||||
|
context?: {
|
||||||
|
actorGold: number;
|
||||||
|
actorRice: number;
|
||||||
|
citySecurity?: number;
|
||||||
|
nationGold?: number;
|
||||||
|
nationRice?: number;
|
||||||
|
nationLevel?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
|
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
|
||||||
export const TURN_COMMAND_NATION_COLORS = [
|
export const TURN_COMMAND_NATION_COLORS = [
|
||||||
'#FF0000', '#800000', '#A0522D', '#FF6347', '#FFA500', '#FFDAB9', '#FFD700', '#FFFF00',
|
'#FF0000',
|
||||||
'#7CFC00', '#00FF00', '#808000', '#008000', '#2E8B57', '#008080', '#20B2AA', '#6495ED',
|
'#800000',
|
||||||
'#7FFFD4', '#AFEEEE', '#87CEEB', '#00FFFF', '#00BFFF', '#0000FF', '#000080', '#483D8B',
|
'#A0522D',
|
||||||
'#7B68EE', '#BA55D3', '#800080', '#FF00FF', '#FFC0CB', '#F5F5DC', '#E0FFFF', '#FFFFFF',
|
'#FF6347',
|
||||||
|
'#FFA500',
|
||||||
|
'#FFDAB9',
|
||||||
|
'#FFD700',
|
||||||
|
'#FFFF00',
|
||||||
|
'#7CFC00',
|
||||||
|
'#00FF00',
|
||||||
|
'#808000',
|
||||||
|
'#008000',
|
||||||
|
'#2E8B57',
|
||||||
|
'#008080',
|
||||||
|
'#20B2AA',
|
||||||
|
'#6495ED',
|
||||||
|
'#7FFFD4',
|
||||||
|
'#AFEEEE',
|
||||||
|
'#87CEEB',
|
||||||
|
'#00FFFF',
|
||||||
|
'#00BFFF',
|
||||||
|
'#0000FF',
|
||||||
|
'#000080',
|
||||||
|
'#483D8B',
|
||||||
|
'#7B68EE',
|
||||||
|
'#BA55D3',
|
||||||
|
'#800080',
|
||||||
|
'#FF00FF',
|
||||||
|
'#FFC0CB',
|
||||||
|
'#F5F5DC',
|
||||||
|
'#E0FFFF',
|
||||||
|
'#FFFFFF',
|
||||||
'#A9A9A9',
|
'#A9A9A9',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ const buildGeneral = (id: number, userId: string, gold = 2_000): GeneralRow =>
|
|||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
name: `장수${id}`,
|
name: `장수${id}`,
|
||||||
|
picture: `${id}.jpg`,
|
||||||
|
imageServer: id % 2,
|
||||||
leadership: 70 + id,
|
leadership: 70 + id,
|
||||||
strength: 60 + id,
|
strength: 60 + id,
|
||||||
intel: 50 + id,
|
intel: 50 + id,
|
||||||
@@ -296,6 +298,10 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
const sections = await ownerCaller.tournament.getRankings();
|
const sections = await ownerCaller.tournament.getRankings();
|
||||||
expect(sections).toHaveLength(4);
|
expect(sections).toHaveLength(4);
|
||||||
expect(sections[0]?.entries.map((entry) => entry.generalId)).toEqual([second.id, first.id]);
|
expect(sections[0]?.entries.map((entry) => entry.generalId)).toEqual([second.id, first.id]);
|
||||||
|
expect(sections[0]?.entries[0]).toMatchObject({
|
||||||
|
picture: '2.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const generalLessCaller = appRouter.createCaller(
|
const generalLessCaller = appRouter.createCaller(
|
||||||
buildContext({ redis, transport, generals: [first, second], userId: 'user-3', rankRows })
|
buildContext({ redis, transport, generals: [first, second], userId: 'user-3', rankRows })
|
||||||
@@ -303,6 +309,33 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
await expect(generalLessCaller.tournament.getRankings()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
await expect(generalLessCaller.tournament.getRankings()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('joins current dedicated icon metadata to the public tournament snapshot', async () => {
|
||||||
|
const redis = new MemoryRedis();
|
||||||
|
const transport = new TournamentTransport();
|
||||||
|
const owner = buildGeneral(11, 'user-1');
|
||||||
|
const rival = buildGeneral(12, 'user-2');
|
||||||
|
await setTournamentFixture(redis, {
|
||||||
|
stage: 7,
|
||||||
|
phase: 0,
|
||||||
|
type: 0,
|
||||||
|
auto: true,
|
||||||
|
openYear: 193,
|
||||||
|
openMonth: 1,
|
||||||
|
termSeconds: 60,
|
||||||
|
nextAt: '2026-07-26T01:00:00.000Z',
|
||||||
|
});
|
||||||
|
const caller = appRouter.createCaller(
|
||||||
|
buildContext({ redis, transport, generals: [owner, rival], userId: 'user-1' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await caller.tournament.getSnapshot();
|
||||||
|
|
||||||
|
expect(snapshot.participants).toEqual([
|
||||||
|
expect.objectContaining({ id: 11, picture: '11.jpg', imageServer: 1 }),
|
||||||
|
expect.objectContaining({ id: 12, picture: '12.jpg', imageServer: 0 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('refunds gold when the tournament bet rank update fails', async () => {
|
it('refunds gold when the tournament bet rank update fails', async () => {
|
||||||
const redis = new MemoryRedis();
|
const redis = new MemoryRedis();
|
||||||
const transport = new TournamentTransport();
|
const transport = new TournamentTransport();
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ integration('gateway runtime action consumer', () => {
|
|||||||
create: {
|
create: {
|
||||||
profileName,
|
profileName,
|
||||||
profile: 'runtime',
|
profile: 'runtime',
|
||||||
|
instanceKey: 'consumer-integration',
|
||||||
|
currentScenario: 'consumer-integration',
|
||||||
scenario: 'consumer-integration',
|
scenario: 'consumer-integration',
|
||||||
apiPort: 15998,
|
apiPort: 15998,
|
||||||
status: 'RUNNING',
|
status: 'RUNNING',
|
||||||
|
|||||||
@@ -274,6 +274,8 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
|
|||||||
'src',
|
'src',
|
||||||
'https://sam-image.hided.net/icons/22.jpg'
|
'https://sam-image.hided.net/icons/22.jpg'
|
||||||
);
|
);
|
||||||
|
await expect(page.locator('.article-header .date')).toHaveText('07-26 19:20');
|
||||||
|
await expect(page.locator('.comment-row .date')).toHaveText('07-26 19:25');
|
||||||
if (artifactRoot) {
|
if (artifactRoot) {
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: resolve(artifactRoot, 'board-core-desktop.png'),
|
path: resolve(artifactRoot, 'board-core-desktop.png'),
|
||||||
@@ -392,7 +394,9 @@ test('uses the ref 500px responsive form widths', async ({ page }) => {
|
|||||||
await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('retains article and comment input after a failed mutation, then reloads after success', async ({ page }, testInfo) => {
|
test('retains article and comment input after a failed mutation, then reloads after success', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
const state: BoardFixture = {
|
const state: BoardFixture = {
|
||||||
permission: 2,
|
permission: 2,
|
||||||
canMeeting: true,
|
canMeeting: true,
|
||||||
@@ -408,7 +412,9 @@ test('retains article and comment input after a failed mutation, then reloads af
|
|||||||
await page.locator('#board-title').fill('새 제목');
|
await page.locator('#board-title').fill('새 제목');
|
||||||
await page.locator('#board-content').fill('새 내용');
|
await page.locator('#board-content').fill('새 내용');
|
||||||
await page.locator('#submitArticle').click();
|
await page.locator('#submitArticle').click();
|
||||||
const articleToast = page.getByTestId('game-toast').filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' });
|
const articleToast = page
|
||||||
|
.getByTestId('game-toast')
|
||||||
|
.filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' });
|
||||||
await expect(articleToast).toHaveAttribute('data-feedback-kind', 'error');
|
await expect(articleToast).toHaveAttribute('data-feedback-kind', 'error');
|
||||||
await expect(articleToast).toHaveAttribute('role', 'alert');
|
await expect(articleToast).toHaveAttribute('role', 'alert');
|
||||||
await expect(page.locator('#board-title')).toHaveValue('새 제목');
|
await expect(page.locator('#board-title')).toHaveValue('새 제목');
|
||||||
@@ -416,7 +422,13 @@ test('retains article and comment input after a failed mutation, then reloads af
|
|||||||
|
|
||||||
const desktopToastGeometry = await articleToast.evaluate((element) => {
|
const desktopToastGeometry = await articleToast.evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
return { left: rect.left, right: rect.right, top: rect.top, width: rect.width, viewportWidth: window.innerWidth };
|
return {
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
top: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
expect(desktopToastGeometry.left).toBeGreaterThanOrEqual(0);
|
expect(desktopToastGeometry.left).toBeGreaterThanOrEqual(0);
|
||||||
expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth);
|
expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth);
|
||||||
@@ -435,10 +447,14 @@ test('retains article and comment input after a failed mutation, then reloads af
|
|||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth);
|
const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||||
await commentInput.press('Enter');
|
await commentInput.press('Enter');
|
||||||
const commentToast = page.getByTestId('game-toast').filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' });
|
const commentToast = page
|
||||||
|
.getByTestId('game-toast')
|
||||||
|
.filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' });
|
||||||
await expect(commentToast).toBeVisible();
|
await expect(commentToast).toBeVisible();
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom))
|
.poll(async () =>
|
||||||
|
commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom)
|
||||||
|
)
|
||||||
.toBeGreaterThanOrEqual(0);
|
.toBeGreaterThanOrEqual(0);
|
||||||
const mobileToastGeometry = await commentToast.evaluate((element) => {
|
const mobileToastGeometry = await commentToast.evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ const operations = (route: Route) =>
|
|||||||
const inputOptions = {
|
const inputOptions = {
|
||||||
cities: [
|
cities: [
|
||||||
{ value: 1, label: '업 (아국)' },
|
{ value: 1, label: '업 (아국)' },
|
||||||
{ value: 2, label: '허창 (적국)' },
|
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
|
||||||
],
|
],
|
||||||
nations: [
|
nations: [
|
||||||
{ value: 1, label: '아국', color: '#008000' },
|
{ value: 1, label: '아국', color: '#008000' },
|
||||||
{ value: 2, label: '적국', color: '#800000' },
|
{ value: 2, label: '적국', color: '#800000', description: '수도 허창' },
|
||||||
],
|
],
|
||||||
generals: [
|
generals: [
|
||||||
{ value: 1, label: '장수 (아국 · 업)' },
|
{ value: 1, label: '장수 (아국 · 업)' },
|
||||||
@@ -75,6 +75,14 @@ const inputOptions = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
context: {
|
||||||
|
actorGold: 1000,
|
||||||
|
actorRice: 1000,
|
||||||
|
citySecurity: 500,
|
||||||
|
nationGold: 5000,
|
||||||
|
nationRice: 6000,
|
||||||
|
nationLevel: 1,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const commandTable = {
|
const commandTable = {
|
||||||
general: [
|
general: [
|
||||||
@@ -152,6 +160,27 @@ const commandTable = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
category: '외교',
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
key: 'che_선전포고',
|
||||||
|
name: '선전포고',
|
||||||
|
reqArg: true,
|
||||||
|
possible: true,
|
||||||
|
status: 'needsInput',
|
||||||
|
inputFields: [
|
||||||
|
{
|
||||||
|
key: 'destNationId',
|
||||||
|
label: '대상 국가',
|
||||||
|
kind: 'select',
|
||||||
|
required: true,
|
||||||
|
optionSource: 'nations',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
inputOptions,
|
inputOptions,
|
||||||
};
|
};
|
||||||
@@ -177,8 +206,46 @@ const generalContext = {
|
|||||||
dedication: 0,
|
dedication: 0,
|
||||||
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
|
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
|
||||||
},
|
},
|
||||||
city: { id: 1, name: '업', level: 8, region: 1, population: 1000, populationMax: 2000 },
|
city: {
|
||||||
nation: { id: 1, name: '아국', color: '#008000', level: 1 },
|
id: 1,
|
||||||
|
name: '업',
|
||||||
|
level: 8,
|
||||||
|
levelName: '특',
|
||||||
|
region: 1,
|
||||||
|
regionName: '하북',
|
||||||
|
nationId: 1,
|
||||||
|
nationName: '아국',
|
||||||
|
population: 1000,
|
||||||
|
populationMax: 2000,
|
||||||
|
agriculture: 100,
|
||||||
|
agricultureMax: 200,
|
||||||
|
commerce: 100,
|
||||||
|
commerceMax: 200,
|
||||||
|
security: 100,
|
||||||
|
securityMax: 200,
|
||||||
|
trust: 70,
|
||||||
|
trade: 100,
|
||||||
|
defence: 100,
|
||||||
|
defenceMax: 200,
|
||||||
|
wall: 100,
|
||||||
|
wallMax: 200,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 0,
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
id: 1,
|
||||||
|
name: '아국',
|
||||||
|
color: '#008000',
|
||||||
|
level: 1,
|
||||||
|
levelName: '호족',
|
||||||
|
gold: 5000,
|
||||||
|
rice: 6000,
|
||||||
|
tech: 100,
|
||||||
|
typeCode: 'che_중립',
|
||||||
|
typeName: '중립',
|
||||||
|
capitalCityId: 1,
|
||||||
|
capitalCityName: '업',
|
||||||
|
},
|
||||||
settings: {},
|
settings: {},
|
||||||
penalties: {},
|
penalties: {},
|
||||||
};
|
};
|
||||||
@@ -247,8 +314,11 @@ const install = async (page: Page, rejectGeneral = false) => {
|
|||||||
if (name === 'world.getMapLayout')
|
if (name === 'world.getMapLayout')
|
||||||
return response({
|
return response({
|
||||||
mapName: 'che',
|
mapName: 'che',
|
||||||
cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [] }],
|
cityList: [
|
||||||
regionMap: { 1: '하북' },
|
{ id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [2] },
|
||||||
|
{ id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] },
|
||||||
|
],
|
||||||
|
regionMap: { 1: '하북', 2: '예주' },
|
||||||
levelMap: { 8: '특' },
|
levelMap: { 8: '특' },
|
||||||
});
|
});
|
||||||
if (name === 'auth.status') return response({ ok: true });
|
if (name === 'auth.status') return response({ ok: true });
|
||||||
@@ -271,8 +341,14 @@ const install = async (page: Page, rejectGeneral = false) => {
|
|||||||
startYear: 180,
|
startYear: 180,
|
||||||
year: 200,
|
year: 200,
|
||||||
month: 1,
|
month: 1,
|
||||||
cityList: [[1, 8, 0, 1, 1, 1]],
|
cityList: [
|
||||||
nationList: [[1, '아국', '#008000', 1]],
|
[1, 8, 0, 1, 1, 1],
|
||||||
|
[2, 7, 40, 2, 2, 1],
|
||||||
|
],
|
||||||
|
nationList: [
|
||||||
|
[1, '아국', '#008000', 1],
|
||||||
|
[2, '적국', '#800000', 2],
|
||||||
|
],
|
||||||
spyList: {},
|
spyList: {},
|
||||||
shownByGeneralList: [],
|
shownByGeneralList: [],
|
||||||
myCity: 1,
|
myCity: 1,
|
||||||
@@ -341,13 +417,35 @@ const install = async (page: Page, rejectGeneral = false) => {
|
|||||||
|
|
||||||
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.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
|
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
|
||||||
const form = page.getByTestId('command-argument-form');
|
const form = page.getByTestId('command-argument-form');
|
||||||
await expect(form).toBeVisible();
|
await expect(form).toBeVisible();
|
||||||
await form.locator('select').selectOption('2');
|
await expect(form.getByTestId('command-argument-map')).toBeVisible();
|
||||||
|
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 화계를 실행합니다.');
|
||||||
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 0칸');
|
||||||
|
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).click();
|
||||||
|
await expect(form.locator('select')).toHaveValue('2');
|
||||||
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 1칸');
|
||||||
|
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).hover();
|
||||||
|
expect(
|
||||||
|
await form
|
||||||
|
.getByTestId('command-argument-map')
|
||||||
|
.locator('.map-city')
|
||||||
|
.nth(1)
|
||||||
|
.evaluate((element) => getComputedStyle(element).cursor)
|
||||||
|
).toBe('pointer');
|
||||||
|
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).focus();
|
||||||
|
await expect(form.getByTestId('command-argument-map').locator('.map-city').nth(1)).toBeFocused();
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => {
|
||||||
|
const area = element.querySelector<HTMLElement>('.map-area')!;
|
||||||
|
const rect = area.getBoundingClientRect();
|
||||||
|
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('화계');
|
||||||
|
|
||||||
@@ -379,6 +477,9 @@ test('enters general and nation command arguments and sends exact values', async
|
|||||||
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":2');
|
||||||
|
|
||||||
|
expect(mapGeometry.width).toBeGreaterThan(650);
|
||||||
|
expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2);
|
||||||
|
|
||||||
expect(geometry.width).toBeGreaterThan(200);
|
expect(geometry.width).toBeGreaterThan(200);
|
||||||
expect(geometry.rowHeight).toBeGreaterThanOrEqual(34);
|
expect(geometry.rowHeight).toBeGreaterThanOrEqual(34);
|
||||||
expect(geometry.borderStyle).toBe('solid');
|
expect(geometry.borderStyle).toBe('solid');
|
||||||
@@ -493,6 +594,52 @@ test('shows Ref recruitment details and preserves the 1000px desktop and 500px m
|
|||||||
await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금');
|
await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('uses the map to choose a nation target in the chief command window', async ({ page }) => {
|
||||||
|
await install(page);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await page.goto('/che/chief-center');
|
||||||
|
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: /선전포고/ }).click();
|
||||||
|
const form = picker.getByTestId('command-argument-form');
|
||||||
|
await expect(form.getByTestId('command-argument-guidance')).toContainText('초반 제한');
|
||||||
|
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).click();
|
||||||
|
await expect(form.locator('select')).toHaveValue('2');
|
||||||
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개');
|
||||||
|
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
||||||
|
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fits the city map option window inside the Ref-compatible 500px mobile page', async ({ page }) => {
|
||||||
|
await install(page);
|
||||||
|
await page.setViewportSize({ width: 500, 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: /화계/ }).click();
|
||||||
|
const geometry = await picker.evaluate((element) => {
|
||||||
|
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"] .map-area')!;
|
||||||
|
const pickerRect = element.getBoundingClientRect();
|
||||||
|
const mapRect = map.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
pickerX: pickerRect.x,
|
||||||
|
pickerRight: pickerRect.right,
|
||||||
|
pickerWidth: pickerRect.width,
|
||||||
|
pickerScrollWidth: element.scrollWidth,
|
||||||
|
mapWidth: mapRect.width,
|
||||||
|
mapHeight: mapRect.height,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.pickerX).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(geometry.pickerRight).toBeLessThanOrEqual(500);
|
||||||
|
expect(geometry.pickerWidth).toBeGreaterThanOrEqual(488);
|
||||||
|
expect(geometry.pickerScrollWidth).toBeLessThanOrEqual(geometry.pickerWidth);
|
||||||
|
expect(geometry.mapWidth).toBeGreaterThan(470);
|
||||||
|
expect(geometry.mapHeight / geometry.mapWidth).toBeCloseTo(5 / 7, 2);
|
||||||
|
await page.screenshot({ path: test.info().outputPath('main-city-map-option-mobile.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('keeps the entered command visible and reports a server validation error', async ({ page }) => {
|
test('keeps the entered command visible and reports a server validation error', async ({ page }) => {
|
||||||
await install(page, true);
|
await install(page, true);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, resolve } from 'node:path';
|
import { basename, resolve } from 'node:path';
|
||||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
|
||||||
import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
@@ -20,6 +20,23 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readGeneralPanelImages = async (panel: Locator) =>
|
||||||
|
panel.evaluate((element) =>
|
||||||
|
[...element.querySelectorAll<HTMLElement>('.general-image')].map((image) => {
|
||||||
|
const rect = image.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(image);
|
||||||
|
return {
|
||||||
|
label: image.getAttribute('aria-label'),
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
backgroundImage: style.backgroundImage,
|
||||||
|
backgroundSize: style.backgroundSize,
|
||||||
|
pointerEvents: style.pointerEvents,
|
||||||
|
userSelect: style.userSelect,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
type FixtureState = {
|
type FixtureState = {
|
||||||
permission: 'head' | 'member';
|
permission: 'head' | 'member';
|
||||||
myset: number;
|
myset: number;
|
||||||
@@ -545,9 +562,15 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표
|
|||||||
|
|
||||||
const generalCard = page.locator('.general-card');
|
const generalCard = page.locator('.general-card');
|
||||||
await expect(generalCard).toContainText('성격안전');
|
await expect(generalCard).toContainText('성격안전');
|
||||||
await expect(generalCard).toContainText('전투특기신산');
|
await expect(generalCard).toContainText('특기상재 / 신산');
|
||||||
await expect(generalCard).toContainText('내정특기상재');
|
|
||||||
await expect(generalCard).not.toContainText('che_');
|
await expect(generalCard).not.toContainText('che_');
|
||||||
|
await expect(generalCard).toHaveAttribute('data-general-basic-card', '');
|
||||||
|
const mainImages = await readGeneralPanelImages(generalCard);
|
||||||
|
expect(mainImages).toHaveLength(2);
|
||||||
|
expect(mainImages[0]).toMatchObject({ width: 64, height: 64, pointerEvents: 'none', userSelect: 'none' });
|
||||||
|
expect(mainImages[0]?.backgroundImage).toContain('/icons/default.jpg');
|
||||||
|
expect(mainImages[1]).toMatchObject({ width: 64, height: 64, pointerEvents: 'none', userSelect: 'none' });
|
||||||
|
expect(mainImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
|
||||||
|
|
||||||
const geometry = await nationCard.evaluate((element) => {
|
const geometry = await nationCard.evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
@@ -587,9 +610,9 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명
|
|||||||
await expect(nationCard).toContainText('국가 등급주자사');
|
await expect(nationCard).toContainText('국가 등급주자사');
|
||||||
|
|
||||||
const generalCard = page.locator('.general-card');
|
const generalCard = page.locator('.general-card');
|
||||||
await expect(generalCard.locator('.general-title')).toContainText('검증장수 · 간의대부');
|
await expect(generalCard.locator('.general-title')).toContainText('검증장수 【 간의대부 | 건강 】');
|
||||||
await expect(generalCard).toContainText('병종보병');
|
await expect(generalCard).toContainText('병종보병');
|
||||||
await expect(generalCard).toContainText('계급29품관');
|
await expect(generalCard).toContainText('계급 29품관');
|
||||||
|
|
||||||
const cityCard = page.locator('.city-card');
|
const cityCard = page.locator('.city-card');
|
||||||
await expect(cityCard.locator('.title')).toContainText('【중원 | 특】 업');
|
await expect(cityCard.locator('.title')).toContainText('【중원 | 특】 업');
|
||||||
@@ -646,11 +669,19 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
|
|||||||
expect(mobileWidth).toBe(1016);
|
expect(mobileWidth).toBe(1016);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
|
test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-identity layout', async ({ page }) => {
|
||||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
await install(page, state);
|
await install(page, state);
|
||||||
await page.setViewportSize({ width: 1000, height: 900 });
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
await page.goto('my-page');
|
await page.goto('my-page');
|
||||||
|
await expect(page.locator('.general-table')).toHaveAttribute('data-general-basic-card', '');
|
||||||
|
const myPageImages = await readGeneralPanelImages(page.locator('.general-table'));
|
||||||
|
expect(myPageImages.map(({ width, height }) => ({ width, height }))).toEqual([
|
||||||
|
{ width: 64, height: 64 },
|
||||||
|
{ width: 64, height: 64 },
|
||||||
|
]);
|
||||||
|
expect(myPageImages[0]?.backgroundImage).toContain('/icons/default.jpg');
|
||||||
|
expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
|
||||||
await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관');
|
await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관');
|
||||||
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병');
|
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병');
|
||||||
await expect(page.locator('.item-group')).toContainText('명마');
|
await expect(page.locator('.item-group')).toContainText('명마');
|
||||||
@@ -696,7 +727,7 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(desktop.width).toBe(1000);
|
expect(desktop.width).toBe(1000);
|
||||||
expect(desktop.minWidth).toBe('500px');
|
expect(desktop.minWidth).toBe('0px');
|
||||||
expect(desktop.fontSize).toBe('14px');
|
expect(desktop.fontSize).toBe('14px');
|
||||||
expect(desktop.columns.split(' ')).toHaveLength(2);
|
expect(desktop.columns.split(' ')).toHaveLength(2);
|
||||||
expect(desktop.titleHeight).toBeCloseTo(54, 0);
|
expect(desktop.titleHeight).toBeCloseTo(54, 0);
|
||||||
@@ -766,26 +797,39 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
|
|||||||
expect(state.settingMutations.at(-1)).not.toHaveProperty('generalId');
|
expect(state.settingMutations.at(-1)).not.toHaveProperty('generalId');
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.setViewportSize({ width: 500, height: 900 });
|
await page.setViewportSize({ width: 390, height: 900 });
|
||||||
await page.reload();
|
await page.reload();
|
||||||
const mobile = await page.locator('#container').evaluate((element) => {
|
const mobile = await page.locator('#container').evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
|
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
|
||||||
|
const icon = element.querySelector<HTMLElement>('[data-general-basic-card] .general-icon')!.getBoundingClientRect();
|
||||||
|
const name = element.querySelector<HTMLElement>('[data-general-basic-card] .general-title')!.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
scrollWidth: document.documentElement.scrollWidth,
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns,
|
columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns,
|
||||||
settingsOffset: settings.x - rect.x,
|
settingsOffset: settings.x - rect.x,
|
||||||
settingsWidth: settings.width,
|
settingsWidth: settings.width,
|
||||||
|
identity: {
|
||||||
|
iconRight: icon.right,
|
||||||
|
nameLeft: name.left,
|
||||||
|
iconTop: icon.top,
|
||||||
|
iconBottom: icon.bottom,
|
||||||
|
nameTop: name.top,
|
||||||
|
nameBottom: name.bottom,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(mobile).toMatchObject({
|
expect(mobile).toMatchObject({
|
||||||
width: 500,
|
width: 390,
|
||||||
scrollWidth: 500,
|
scrollWidth: 390,
|
||||||
columns: '500px',
|
columns: '390px',
|
||||||
settingsOffset: 0,
|
settingsOffset: 0,
|
||||||
settingsWidth: 500,
|
settingsWidth: 390,
|
||||||
});
|
});
|
||||||
|
expect(mobile.identity.nameLeft).toBeGreaterThanOrEqual(mobile.identity.iconRight - 1);
|
||||||
|
expect(mobile.identity.nameTop).toBeLessThan(mobile.identity.iconBottom);
|
||||||
|
expect(mobile.identity.nameBottom).toBeGreaterThan(mobile.identity.iconTop);
|
||||||
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1109,10 +1153,15 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
|||||||
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
|
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
|
||||||
await page.getByRole('button', { name: '다음 ▶' }).click();
|
await page.getByRole('button', { name: '다음 ▶' }).click();
|
||||||
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
|
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
|
||||||
await expect(page.locator('.battle-general-name')).toContainText('검증장수 (간의대부)');
|
await expect(page.locator('.battle-general-name')).toContainText('검증장수 【 간의대부 | 건강 】');
|
||||||
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
|
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
|
||||||
await expect(page.locator('.battle-general-extra')).toContainText('병종보병');
|
await expect(page.locator('.battle-general-card')).toContainText('병종보병');
|
||||||
await expect(page.locator('.battle-general-card')).not.toContainText('che_');
|
await expect(page.locator('.battle-general-card')).not.toContainText('che_');
|
||||||
|
await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', '');
|
||||||
|
const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card'));
|
||||||
|
expect(battleImages).toHaveLength(2);
|
||||||
|
expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg');
|
||||||
|
expect(battleImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
|
||||||
await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14);
|
await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14);
|
||||||
await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type NavigationFixture = {
|
|||||||
largeCommandTable?: boolean;
|
largeCommandTable?: boolean;
|
||||||
currentYear?: number;
|
currentYear?: number;
|
||||||
currentMonth?: number;
|
currentMonth?: number;
|
||||||
|
scenarioTitle?: string;
|
||||||
|
latestVote?: { id: number; title: string; hasVoted: boolean } | null;
|
||||||
globalRecords?: Array<{ id: number; text: string }>;
|
globalRecords?: Array<{ id: number; text: string }>;
|
||||||
generalRecords?: Array<{ id: number; text: string }>;
|
generalRecords?: Array<{ id: number; text: string }>;
|
||||||
worldHistory?: Array<{ id: number; text: string }>;
|
worldHistory?: Array<{ id: number; text: string }>;
|
||||||
@@ -309,6 +311,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
year: state.currentYear ?? 185,
|
year: state.currentYear ?? 185,
|
||||||
month: state.currentMonth ?? 1,
|
month: state.currentMonth ?? 1,
|
||||||
turnTerm: 10,
|
turnTerm: 10,
|
||||||
|
scenarioTitle: state.scenarioTitle ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (operation === 'dashboard.getContextBundleDelta') {
|
if (operation === 'dashboard.getContextBundleDelta') {
|
||||||
@@ -421,7 +424,10 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
onlineGenerals: '메뉴검증장수',
|
onlineGenerals: '메뉴검증장수',
|
||||||
nationNotice: '<p>국가 방침</p>',
|
nationNotice: '<p>국가 방침</p>',
|
||||||
lastExecuted: null,
|
lastExecuted: null,
|
||||||
latestVote: { id: 9, title: '메뉴 설문', hasVoted: false },
|
latestVote:
|
||||||
|
state.latestVote === undefined
|
||||||
|
? { id: 9, title: '메뉴 설문', hasVoted: false }
|
||||||
|
: state.latestVote,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (operation === 'board.getAccess') {
|
if (operation === 'board.getAccess') {
|
||||||
@@ -508,7 +514,7 @@ const installRealtimeHarness = async (page: Page) => {
|
|||||||
|
|
||||||
const waitForMain = async (page: Page) => {
|
const waitForMain = async (page: Page) => {
|
||||||
await page.goto('./');
|
await page.goto('./');
|
||||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
await expect(page.locator('.game-shell__title')).toBeVisible();
|
||||||
await expect(page.locator('.main-global-menu').first()).toBeVisible();
|
await expect(page.locator('.main-global-menu').first()).toBeVisible();
|
||||||
await expect(page.locator('.main-nation-menu')).toBeVisible();
|
await expect(page.locator('.main-nation-menu')).toBeVisible();
|
||||||
await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3);
|
await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3);
|
||||||
@@ -561,8 +567,24 @@ const persistArtifact = async (page: Page, name: string) => {
|
|||||||
globalPopup: describe('#mobile-global-menu'),
|
globalPopup: describe('#mobile-global-menu'),
|
||||||
nationPopup: describe('#mobile-nation-menu'),
|
nationPopup: describe('#mobile-nation-menu'),
|
||||||
quickPopup: describe('#mobile-quick-menu'),
|
quickPopup: describe('#mobile-quick-menu'),
|
||||||
|
commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
|
||||||
|
commandDividers: [...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider')].map(
|
||||||
|
(element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||||
|
borderTop: style.borderTop,
|
||||||
|
margin: style.margin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const commandMenu = page.locator('.reserved-command-editor details[open] .menu-items').first();
|
||||||
|
if (await commandMenu.isVisible()) {
|
||||||
|
await commandMenu.screenshot({ path: resolve(target, `${name}-menu.png`) });
|
||||||
|
}
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }),
|
page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }),
|
||||||
writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`),
|
writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`),
|
||||||
@@ -576,6 +598,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
|||||||
nationLevel: 3,
|
nationLevel: 3,
|
||||||
stage: 1,
|
stage: 1,
|
||||||
npcMode: 1,
|
npcMode: 1,
|
||||||
|
scenarioTitle: '메인 화면 검증 시나리오',
|
||||||
generalMeCalls: 0,
|
generalMeCalls: 0,
|
||||||
operations: [],
|
operations: [],
|
||||||
};
|
};
|
||||||
@@ -589,6 +612,45 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
|||||||
await expect(page.locator('.main-mobile-bottom')).toBeHidden();
|
await expect(page.locator('.main-mobile-bottom')).toBeHidden();
|
||||||
await expect(page.locator('.layout-desktop')).toBeVisible();
|
await expect(page.locator('.layout-desktop')).toBeVisible();
|
||||||
await expect(page.locator('.layout-mobile')).toHaveCount(0);
|
await expect(page.locator('.layout-mobile')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1);
|
||||||
|
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
|
||||||
|
await expect(page.locator('.game-shell__subtitle')).not.toContainText('메인 화면 검증 시나리오');
|
||||||
|
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
|
||||||
|
await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문');
|
||||||
|
const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => {
|
||||||
|
const title = element.querySelector<HTMLElement>('.game-shell__title');
|
||||||
|
const subtitle = element.querySelector<HTMLElement>('.game-shell__subtitle');
|
||||||
|
const activity = element.querySelector<HTMLElement>('.activity-status');
|
||||||
|
const tournament = element.querySelector<HTMLElement>('.tournament-status');
|
||||||
|
const survey = element.querySelector<HTMLElement>('.vote-status');
|
||||||
|
if (!title || !subtitle || !activity || !tournament || !survey) {
|
||||||
|
throw new Error('main header status geometry is incomplete');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: title.getBoundingClientRect().toJSON(),
|
||||||
|
subtitle: subtitle.getBoundingClientRect().toJSON(),
|
||||||
|
activity: activity.getBoundingClientRect().toJSON(),
|
||||||
|
tournament: tournament.getBoundingClientRect().toJSON(),
|
||||||
|
survey: survey.getBoundingClientRect().toJSON(),
|
||||||
|
activityColumns: getComputedStyle(activity).gridTemplateColumns,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(headerStatusGeometry.subtitle.y).toBeGreaterThanOrEqual(headerStatusGeometry.title.bottom);
|
||||||
|
expect(headerStatusGeometry.activity.width).toBeCloseTo(666.67, 0);
|
||||||
|
expect(headerStatusGeometry.tournament.width).toBeCloseTo(333.33, 0);
|
||||||
|
expect(headerStatusGeometry.survey.width).toBeCloseTo(333.33, 0);
|
||||||
|
expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(2);
|
||||||
|
const tournamentStatusLink = page.locator('.tournament-status a');
|
||||||
|
const surveyStatusLink = page.locator('.vote-status a');
|
||||||
|
await tournamentStatusLink.hover();
|
||||||
|
await expect
|
||||||
|
.poll(() => tournamentStatusLink.evaluate((element) => getComputedStyle(element).cursor))
|
||||||
|
.toBe('pointer');
|
||||||
|
await surveyStatusLink.focus();
|
||||||
|
await expect(surveyStatusLink).toBeFocused();
|
||||||
|
await expect
|
||||||
|
.poll(() => surveyStatusLink.evaluate((element) => getComputedStyle(element).textDecorationLine))
|
||||||
|
.toContain('underline');
|
||||||
const contentOrder = await page
|
const contentOrder = await page
|
||||||
.locator('.record-zone, [data-menu-position="middle"], .desktop-message-panel, [data-menu-position="bottom"]')
|
.locator('.record-zone, [data-menu-position="middle"], .desktop-message-panel, [data-menu-position="bottom"]')
|
||||||
.evaluateAll((elements) =>
|
.evaluateAll((elements) =>
|
||||||
@@ -642,7 +704,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
|||||||
await expect(gameInfoButton).toBeFocused();
|
await expect(gameInfoButton).toBeFocused();
|
||||||
|
|
||||||
await gameInfoButton.click();
|
await gameInfoButton.click();
|
||||||
await page.getByRole('heading', { name: '전장 현황' }).click();
|
await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click();
|
||||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
|
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
|
||||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||||
});
|
});
|
||||||
@@ -882,6 +944,10 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
await tenthTurnButton.click();
|
await tenthTurnButton.click();
|
||||||
const quickPicker = page.getByTestId('command-picker');
|
const quickPicker = page.getByTestId('command-picker');
|
||||||
await expect(quickPicker).toBeVisible();
|
await expect(quickPicker).toBeVisible();
|
||||||
|
await tenthTurnButton.click();
|
||||||
|
await expect(quickPicker).toBeHidden();
|
||||||
|
await tenthTurnButton.click();
|
||||||
|
await expect(quickPicker).toBeVisible();
|
||||||
const quickPickerAlignment = await quickPicker.evaluate((element) => {
|
const quickPickerAlignment = await quickPicker.evaluate((element) => {
|
||||||
const row = element
|
const row = element
|
||||||
.closest('.reserved-command-editor')
|
.closest('.reserved-command-editor')
|
||||||
@@ -923,6 +989,30 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop);
|
expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop);
|
||||||
expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop);
|
expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop);
|
||||||
expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop);
|
expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop);
|
||||||
|
const rangeMenu = page.locator('[data-main-target="commands"] .range-menu');
|
||||||
|
await rangeMenu.locator('summary').click();
|
||||||
|
const rangeDividers = rangeMenu.locator('.menu-divider');
|
||||||
|
await expect(rangeDividers).toHaveCount(1);
|
||||||
|
await expect(rangeDividers.first()).toBeVisible();
|
||||||
|
expect(await rangeDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
|
||||||
|
'1px solid rgb(68, 68, 68)'
|
||||||
|
);
|
||||||
|
await persistArtifact(page, `${basePath.slice(1)}-command-range-divider-desktop-1200`);
|
||||||
|
await rangeMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
|
||||||
|
await expect(rangeMenu).not.toHaveAttribute('open', '');
|
||||||
|
|
||||||
|
const selectedMenu = page.locator('[data-main-target="commands"] .selected-menu');
|
||||||
|
await selectedMenu.locator('summary').click();
|
||||||
|
const selectedMenuDividers = selectedMenu.locator('.menu-divider');
|
||||||
|
await expect(selectedMenuDividers).toHaveCount(3);
|
||||||
|
await expect(selectedMenuDividers.first()).toBeVisible();
|
||||||
|
expect(await selectedMenuDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
|
||||||
|
'1px solid rgb(68, 68, 68)'
|
||||||
|
);
|
||||||
|
await persistArtifact(page, `${basePath.slice(1)}-command-selected-dividers-desktop-1200`);
|
||||||
|
await selectedMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
|
||||||
|
await expect(selectedMenu).not.toHaveAttribute('open', '');
|
||||||
|
|
||||||
await page.locator('[data-main-target="commands"] .select-command').click();
|
await page.locator('[data-main-target="commands"] .select-command').click();
|
||||||
const picker = page.getByTestId('command-picker');
|
const picker = page.getByTestId('command-picker');
|
||||||
await expect(picker).toBeVisible();
|
await expect(picker).toBeVisible();
|
||||||
@@ -1111,6 +1201,11 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
expect(mobileGeometry.controlBoxes).toHaveLength(3);
|
expect(mobileGeometry.controlBoxes).toHaveLength(3);
|
||||||
expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1);
|
expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1);
|
||||||
await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30);
|
await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30);
|
||||||
|
const mobileTurnButton = page.getByRole('button', { name: '10턴 명령 입력' });
|
||||||
|
await mobileTurnButton.click();
|
||||||
|
await expect(page.getByTestId('command-picker')).toBeVisible();
|
||||||
|
await mobileTurnButton.click();
|
||||||
|
await expect(page.getByTestId('command-picker')).toBeHidden();
|
||||||
await captureProgress('mobile-500');
|
await captureProgress('mobile-500');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1121,6 +1216,8 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
|
|||||||
nationLevel: 3,
|
nationLevel: 3,
|
||||||
stage: 6,
|
stage: 6,
|
||||||
npcMode: 1,
|
npcMode: 1,
|
||||||
|
scenarioTitle: '모바일 검증 시나리오',
|
||||||
|
latestVote: null,
|
||||||
generalMeCalls: 0,
|
generalMeCalls: 0,
|
||||||
operations: [],
|
operations: [],
|
||||||
};
|
};
|
||||||
@@ -1139,6 +1236,27 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
|
|||||||
await expect(page.locator('.main-mobile-bottom')).toBeVisible();
|
await expect(page.locator('.main-mobile-bottom')).toBeVisible();
|
||||||
|
|
||||||
await page.setViewportSize({ width: 500, height: 900 });
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1);
|
||||||
|
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
|
||||||
|
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 베팅 진행중');
|
||||||
|
await expect(page.locator('.vote-status')).toHaveText('설문: 진행 중인 설문 없음');
|
||||||
|
const activityGeometry = await page.locator('.activity-status').evaluate((element) => {
|
||||||
|
const tournament = element.querySelector<HTMLElement>('.tournament-status');
|
||||||
|
const survey = element.querySelector<HTMLElement>('.vote-status');
|
||||||
|
if (!tournament || !survey) throw new Error('activity status is incomplete');
|
||||||
|
return {
|
||||||
|
width: element.getBoundingClientRect().width,
|
||||||
|
tournamentWidth: tournament.getBoundingClientRect().width,
|
||||||
|
surveyWidth: survey.getBoundingClientRect().width,
|
||||||
|
columns: getComputedStyle(element).gridTemplateColumns,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(activityGeometry).toMatchObject({
|
||||||
|
width: 500,
|
||||||
|
tournamentWidth: 250,
|
||||||
|
surveyWidth: 250,
|
||||||
|
columns: '250px 250px',
|
||||||
|
});
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
page
|
page
|
||||||
@@ -1175,6 +1293,125 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
|
|||||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-500`);
|
await persistArtifact(page, `${basePath.slice(1)}-mobile-500`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('real mobile devices initially fit the complete 500px game canvas', async ({ browser }, testInfo) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const configuredBaseUrl = testInfo.project.use.baseURL;
|
||||||
|
if (typeof configuredBaseUrl !== 'string') {
|
||||||
|
throw new Error('Playwright baseURL is required for the mobile viewport contract');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceWidths = [360, 390, 480];
|
||||||
|
const measurements: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const deviceWidth of deviceWidths) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
baseURL: configuredBaseUrl,
|
||||||
|
viewport: { width: deviceWidth, height: 844 },
|
||||||
|
screen: { width: deviceWidth, height: 844 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
});
|
||||||
|
const mobilePage = await context.newPage();
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 5,
|
||||||
|
permission: 2,
|
||||||
|
nationLevel: 3,
|
||||||
|
stage: 6,
|
||||||
|
npcMode: 1,
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
};
|
||||||
|
await installFixture(mobilePage, state);
|
||||||
|
await waitForMain(mobilePage);
|
||||||
|
|
||||||
|
const mainGeometry = await mobilePage.locator('.main-page').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
|
||||||
|
screenWidth: screen.availWidth,
|
||||||
|
innerWidth: window.innerWidth,
|
||||||
|
layoutViewportWidth: document.documentElement.clientWidth,
|
||||||
|
visualViewportWidth: window.visualViewport?.width ?? null,
|
||||||
|
visualViewportScale: window.visualViewport?.scale ?? null,
|
||||||
|
documentScrollWidth: document.documentElement.scrollWidth,
|
||||||
|
canvas: {
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
width: rect.width,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mainGeometry.viewportMeta).toBe('width=500');
|
||||||
|
expect(mainGeometry.screenWidth).toBe(deviceWidth);
|
||||||
|
expect(mainGeometry.layoutViewportWidth).toBe(500);
|
||||||
|
expect(mainGeometry.visualViewportWidth).toBeCloseTo(500, 2);
|
||||||
|
expect(mainGeometry.visualViewportScale).toBeCloseTo(deviceWidth / 500, 2);
|
||||||
|
expect(mainGeometry.documentScrollWidth).toBeLessThanOrEqual(mainGeometry.innerWidth);
|
||||||
|
expect(mainGeometry.canvas).toEqual({ left: 0, right: 500, width: 500 });
|
||||||
|
expect(mainGeometry.canvas.right).toBeLessThanOrEqual((mainGeometry.visualViewportWidth ?? 0) + 0.01);
|
||||||
|
if (artifactRoot) {
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
await mobilePage.screenshot({
|
||||||
|
path: resolve(artifactRoot, `initial-mobile-fit-${deviceWidth}.png`),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeGeometry: Record<string, unknown> = {};
|
||||||
|
if (deviceWidth === 390) {
|
||||||
|
for (const target of [
|
||||||
|
'chief-center',
|
||||||
|
'battle-center',
|
||||||
|
'inherit',
|
||||||
|
'nation-betting',
|
||||||
|
]) {
|
||||||
|
await mobilePage.goto(target);
|
||||||
|
await expect
|
||||||
|
.poll(() => mobilePage.locator('#app').evaluate((element) => getComputedStyle(element).minWidth))
|
||||||
|
.toBe('500px');
|
||||||
|
routeGeometry[target] = await mobilePage.locator('#app').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
|
||||||
|
layoutViewportWidth: document.documentElement.clientWidth,
|
||||||
|
visualViewportWidth: window.visualViewport?.width ?? null,
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
width: rect.width,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const geometry = routeGeometry[target] as {
|
||||||
|
viewportMeta: string;
|
||||||
|
layoutViewportWidth: number;
|
||||||
|
visualViewportWidth: number;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
expect(geometry.viewportMeta).toBe('width=500');
|
||||||
|
expect(geometry.layoutViewportWidth).toBe(500);
|
||||||
|
expect(geometry.visualViewportWidth).toBeCloseTo(500, 2);
|
||||||
|
expect(geometry.left).toBeCloseTo(0, 2);
|
||||||
|
expect(geometry.right).toBeCloseTo(500, 2);
|
||||||
|
expect(geometry.width).toBeCloseTo(500, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
measurements[String(deviceWidth)] = { main: mainGeometry, routes: routeGeometry };
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
await writeFile(
|
||||||
|
resolve(artifactRoot, 'initial-mobile-fit-computed-dom.json'),
|
||||||
|
`${JSON.stringify(measurements, null, 2)}\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => {
|
test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => {
|
||||||
const state: NavigationFixture = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 1,
|
officerLevel: 1,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const general = {
|
|||||||
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||||
experienceLevel: 9,
|
experienceLevel: 9,
|
||||||
dedicationLevel: 1,
|
dedicationLevel: 1,
|
||||||
|
dedicationText: '30품관',
|
||||||
|
bill: 600,
|
||||||
injury: 0,
|
injury: 0,
|
||||||
gold: 1000,
|
gold: 1000,
|
||||||
rice: 2000,
|
rice: 2000,
|
||||||
@@ -28,6 +30,24 @@ const general = {
|
|||||||
refreshScoreTotal: 10,
|
refreshScoreTotal: 10,
|
||||||
permission: 'normal',
|
permission: 'normal',
|
||||||
};
|
};
|
||||||
|
const otherGeneral = {
|
||||||
|
...general,
|
||||||
|
id: 2,
|
||||||
|
name: '다른장수',
|
||||||
|
npcState: 1,
|
||||||
|
stats: { leadership: 40, strength: 80, intelligence: 65 },
|
||||||
|
experienceLevel: 12,
|
||||||
|
dedicationLevel: 3,
|
||||||
|
dedicationText: '28품관',
|
||||||
|
bill: 1000,
|
||||||
|
gold: 3000,
|
||||||
|
rice: 500,
|
||||||
|
personality: { key: '용장', name: '용장', info: '공격적인 성격' },
|
||||||
|
specialDomestic: { key: '상재', name: '상재', info: '상업 특기' },
|
||||||
|
specialWar: { key: '돌격', name: '돌격', info: '전투 특기' },
|
||||||
|
belong: 4,
|
||||||
|
refreshScoreTotal: 20,
|
||||||
|
};
|
||||||
const install = async (page: Page, secretAllowed = true) => {
|
const install = async (page: Page, secretAllowed = true) => {
|
||||||
await page.addInitScript((profile) => {
|
await page.addInitScript((profile) => {
|
||||||
localStorage.setItem('sammo-game-token', 'ga_general');
|
localStorage.setItem('sammo-game-token', 'ga_general');
|
||||||
@@ -42,7 +62,7 @@ const install = async (page: Page, secretAllowed = true) => {
|
|||||||
return response({
|
return response({
|
||||||
nation: { id: 1, name: '위', color: '#008000', level: 3 },
|
nation: { id: 1, name: '위', color: '#008000', level: 3 },
|
||||||
viewer: { generalId: 1, permission: 0 },
|
viewer: { generalId: 1, permission: 0 },
|
||||||
generals: [general],
|
generals: [general, otherGeneral],
|
||||||
});
|
});
|
||||||
if (operation === 'nation.getSecretGeneralList') {
|
if (operation === 'nation.getSecretGeneralList') {
|
||||||
if (!secretAllowed)
|
if (!secretAllowed)
|
||||||
@@ -108,19 +128,91 @@ test('nation generals keeps the 1000px legacy grid and redacted member columns',
|
|||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
await page.goto('nation/generals');
|
await page.goto('nation/generals');
|
||||||
await expect(page.locator('#nation-general-list')).toContainText('테스트장수');
|
await expect(page.locator('#nation-general-list')).toContainText('테스트장수');
|
||||||
await expect(page.locator('#nation-general-list')).toContainText('?');
|
|
||||||
const computed = await page.locator('.general-page').evaluate((element) => {
|
const computed = await page.locator('.general-page').evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const style = getComputedStyle(element);
|
const style = getComputedStyle(element);
|
||||||
return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily };
|
return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily };
|
||||||
});
|
});
|
||||||
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' });
|
expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '14px' });
|
||||||
expect(computed.fontFamily).toContain('Times New Roman');
|
expect(computed.fontFamily).toContain('Pretendard');
|
||||||
expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe(
|
expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe(
|
||||||
'separate'
|
'separate'
|
||||||
);
|
);
|
||||||
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030);
|
expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1000);
|
||||||
expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66);
|
expect((await page.locator('#nation-general-list tbody tr').first().boundingBox())?.height).toBe(68);
|
||||||
|
await page.getByRole('button', { name: '보기 모드⌄' }).click();
|
||||||
|
await page.getByRole('button', { name: '전투', exact: true }).click();
|
||||||
|
await expect(page.locator('#nation-general-list')).toContainText('?');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nation generals restores Ref group, saved view, sort, and Korean search behavior', async ({ page }, testInfo) => {
|
||||||
|
await install(page);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await page.goto('nation/generals');
|
||||||
|
const table = page.locator('#nation-general-list');
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('core-initial.png'), fullPage: true });
|
||||||
|
|
||||||
|
const statGroupButton = page.getByRole('button', { name: '능력치 접기' });
|
||||||
|
await expect(statGroupButton).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
|
||||||
|
await statGroupButton.hover();
|
||||||
|
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(48, 54, 56)');
|
||||||
|
await statGroupButton.focus();
|
||||||
|
await expect(statGroupButton).toBeFocused();
|
||||||
|
expect(await statGroupButton.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe('solid');
|
||||||
|
await statGroupButton.click();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('core-stat-collapsed.png'), fullPage: true });
|
||||||
|
await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(table.locator('thead')).toContainText('통|무|지');
|
||||||
|
await expect(table.locator('tr[data-general-id="1"]')).toContainText('70|60|50');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '능력치 펼치기' }).click();
|
||||||
|
await page.getByLabel('장수명 필터').fill('ㅌㅅㅌㅈㅅ');
|
||||||
|
await expect(table.locator('tr[data-general-id="1"]')).toBeVisible();
|
||||||
|
await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0);
|
||||||
|
await page.getByLabel('장수명 필터').fill('');
|
||||||
|
await page.getByLabel('통솔 필터').fill('>= 60');
|
||||||
|
await expect(table.locator('tr[data-general-id="1"]')).toBeVisible();
|
||||||
|
await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0);
|
||||||
|
await page.getByLabel('통솔 필터').fill('');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '통솔 정렬' }).click();
|
||||||
|
await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '1');
|
||||||
|
await page.getByRole('button', { name: '통솔 정렬' }).click();
|
||||||
|
await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '2');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '능력치 접기' }).click();
|
||||||
|
await page.getByRole('button', { name: '열 선택⌄' }).click();
|
||||||
|
await page.getByLabel('쌀', { exact: true }).uncheck();
|
||||||
|
await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0);
|
||||||
|
await page.getByRole('button', { name: '보기 모드⌄' }).click();
|
||||||
|
page.once('dialog', async (dialog) => {
|
||||||
|
expect(dialog.type()).toBe('prompt');
|
||||||
|
await dialog.accept('내 보기');
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: /보관하기/ }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
settings: localStorage.getItem('GeneralListDisplaySetting'),
|
||||||
|
last: localStorage.getItem('LastUsedSettingsKey_pageNationGeneral'),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.toMatchObject({ settings: expect.stringContaining('내 보기'), last: '[false,"내 보기"]' });
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0);
|
||||||
|
await page.getByRole('button', { name: '보기 모드⌄' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: '내 보기', exact: true })).toBeVisible();
|
||||||
|
page.once('dialog', async (dialog) => {
|
||||||
|
expect(dialog.type()).toBe('confirm');
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: '내 보기 설정 삭제' }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => localStorage.getItem('GeneralListDisplaySetting')))
|
||||||
|
.not.toContain('내 보기');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => {
|
test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => {
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { mkdir, readFile } from 'node:fs/promises';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||||
|
|
||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
|
const responsiveArtifactDir = process.env.TOURNAMENT_RESPONSIVE_ARTIFACT_DIR;
|
||||||
const imageRoots = [
|
const imageRoots = [
|
||||||
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []),
|
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []),
|
||||||
resolve(repositoryRoot, '../image/game'),
|
resolve(repositoryRoot, '../image/game'),
|
||||||
resolve(repositoryRoot, '../../image/game'),
|
resolve(repositoryRoot, '../../image/game'),
|
||||||
];
|
];
|
||||||
|
const iconRoots = [
|
||||||
|
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'icons')] : []),
|
||||||
|
resolve(repositoryRoot, '../image/icons'),
|
||||||
|
resolve(repositoryRoot, '../../image/icons'),
|
||||||
|
resolve(repositoryRoot, '../../sam_rebuild/image/icons'),
|
||||||
|
];
|
||||||
const names = [
|
const names = [
|
||||||
'관우',
|
'관우',
|
||||||
'장료',
|
'장료',
|
||||||
@@ -35,6 +42,8 @@ const participants = names.map((name, index) => ({
|
|||||||
strength: 80,
|
strength: 80,
|
||||||
intel: 80,
|
intel: 80,
|
||||||
level: 10,
|
level: 10,
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
groupId: 10 + (index % 8),
|
groupId: 10 + (index % 8),
|
||||||
groupNo: Math.floor(index / 8),
|
groupNo: Math.floor(index / 8),
|
||||||
win: 3 - (index % 2),
|
win: 3 - (index % 2),
|
||||||
@@ -88,6 +97,26 @@ const readReferenceImage = async (filename: string): Promise<Buffer> => {
|
|||||||
throw new Error(`Reference image not found: ${filename}`);
|
throw new Error(`Reference image not found: ${filename}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readReferenceIcon = async (filename: string): Promise<Buffer> => {
|
||||||
|
for (const iconRoot of iconRoots) {
|
||||||
|
try {
|
||||||
|
return await readFile(resolve(iconRoot, filename));
|
||||||
|
} catch {
|
||||||
|
// Worktrees can be nested at different depths.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Reference icon not found: ${filename}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistScreenshot = async (page: Page, name: string, fallbackPath: string) => {
|
||||||
|
if (!responsiveArtifactDir) {
|
||||||
|
await page.screenshot({ path: fallbackPath, fullPage: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await mkdir(responsiveArtifactDir, { recursive: true });
|
||||||
|
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
|
||||||
|
};
|
||||||
|
|
||||||
const installFixture = async (page: Page) => {
|
const installFixture = async (page: Page) => {
|
||||||
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');
|
||||||
@@ -98,6 +127,9 @@ const installFixture = async (page: Page) => {
|
|||||||
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceImage(filename) });
|
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceImage(filename) });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await page.route('**/icons/default.jpg', async (route) => {
|
||||||
|
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceIcon('default.jpg') });
|
||||||
|
});
|
||||||
await page.route(gameTrpcRoute, async (route) => {
|
await page.route(gameTrpcRoute, async (route) => {
|
||||||
const results = operationNames(route).map((operation) => {
|
const results = operationNames(route).map((operation) => {
|
||||||
if (operation === 'auth.status') return response({ ok: true });
|
if (operation === 'auth.status') return response({ ok: true });
|
||||||
@@ -125,12 +157,43 @@ const installFixture = async (page: Page) => {
|
|||||||
}
|
}
|
||||||
if (operation === 'tournament.getBettingSummary') {
|
if (operation === 'tournament.getBettingSummary') {
|
||||||
return response({
|
return response({
|
||||||
totals: Object.fromEntries(participants.map((participant, index) => [participant.id, 100 + index * 10])),
|
totals: Object.fromEntries(
|
||||||
|
participants.map((participant, index) => [participant.id, 100 + index * 10])
|
||||||
|
),
|
||||||
myTotals: {},
|
myTotals: {},
|
||||||
totalAmount: 2800,
|
totalAmount: 2800,
|
||||||
myAmount: 0,
|
myAmount: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (operation === 'tournament.getRankings') {
|
||||||
|
return response(
|
||||||
|
[
|
||||||
|
['tt', '전 력 전', '종합'],
|
||||||
|
['tl', '통 솔 전', '통솔'],
|
||||||
|
['ts', '일 기 토', '무력'],
|
||||||
|
['ti', '설 전', '지력'],
|
||||||
|
].map(([prefix, title, statLabel]) => ({
|
||||||
|
prefix,
|
||||||
|
title,
|
||||||
|
statLabel,
|
||||||
|
entries: participants.slice(0, 6).map((participant, index) => ({
|
||||||
|
rank: index + 1,
|
||||||
|
generalId: participant.id,
|
||||||
|
name: participant.name,
|
||||||
|
picture: participant.picture,
|
||||||
|
imageServer: participant.imageServer,
|
||||||
|
npcState: 0,
|
||||||
|
stat: 240 - index,
|
||||||
|
games: 10,
|
||||||
|
win: 7,
|
||||||
|
draw: 1,
|
||||||
|
lose: 2,
|
||||||
|
score: 22 - index,
|
||||||
|
prizes: 3,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
return response(null);
|
return response(null);
|
||||||
});
|
});
|
||||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||||
@@ -162,16 +225,20 @@ test('desktop bracket connects every real general slot to the next round', async
|
|||||||
connectorCenter: firstConnector.x + firstConnector.width / 2,
|
connectorCenter: firstConnector.x + firstConnector.width / 2,
|
||||||
championCenter: champion.x + champion.width / 2,
|
championCenter: champion.x + champion.width / 2,
|
||||||
finalistCenters: finalists.map((rect) => rect.x + rect.width / 2),
|
finalistCenters: finalists.map((rect) => rect.x + rect.width / 2),
|
||||||
connectorQuarters: [firstConnector.x + firstConnector.width / 4, firstConnector.x + (firstConnector.width * 3) / 4],
|
connectorQuarters: [
|
||||||
|
firstConnector.x + firstConnector.width / 4,
|
||||||
|
firstConnector.x + (firstConnector.width * 3) / 4,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(geometry.canvasWidth).toBe(2000);
|
expect(geometry.canvasWidth).toBeGreaterThanOrEqual(1000);
|
||||||
|
expect(geometry.canvasWidth).toBeLessThanOrEqual(1200);
|
||||||
expect(Math.abs(geometry.connectorCenter - geometry.championCenter)).toBeLessThan(1);
|
expect(Math.abs(geometry.connectorCenter - geometry.championCenter)).toBeLessThan(1);
|
||||||
expect(geometry.finalistCenters).toHaveLength(2);
|
expect(geometry.finalistCenters).toHaveLength(2);
|
||||||
expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).toBeLessThan(1);
|
expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).toBeLessThan(1);
|
||||||
expect(Math.abs(geometry.finalistCenters[1]! - geometry.connectorQuarters[1]!)).toBeLessThan(1);
|
expect(Math.abs(geometry.finalistCenters[1]! - geometry.connectorQuarters[1]!)).toBeLessThan(1);
|
||||||
|
|
||||||
await page.screenshot({ path: testInfo.outputPath('tournament-bracket-desktop.webp'), fullPage: true });
|
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mobile bracket shows every round and general within the handheld width', async ({ page }, testInfo) => {
|
test('mobile bracket shows every round and general within the handheld width', async ({ page }, testInfo) => {
|
||||||
@@ -197,5 +264,62 @@ test('mobile bracket shows every round and general within the handheld width', a
|
|||||||
expect(bounds.width).toBe(390);
|
expect(bounds.width).toBe(390);
|
||||||
expect(bounds.minX).toBeGreaterThanOrEqual(0);
|
expect(bounds.minX).toBeGreaterThanOrEqual(0);
|
||||||
expect(bounds.maxX).toBeLessThanOrEqual(390);
|
expect(bounds.maxX).toBeLessThanOrEqual(390);
|
||||||
await page.screenshot({ path: testInfo.outputPath('tournament-bracket-mobile.webp'), fullPage: true });
|
const identity = await bracket
|
||||||
|
.locator('.mobile-bracket-name')
|
||||||
|
.first()
|
||||||
|
.evaluate((element) => {
|
||||||
|
const icon = element.querySelector('img')!.getBoundingClientRect();
|
||||||
|
const name = element.querySelector<HTMLElement>('.general-identity-name')!.getBoundingClientRect();
|
||||||
|
return { iconRight: icon.right, nameLeft: name.left, iconY: icon.y, nameY: name.y };
|
||||||
|
});
|
||||||
|
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight);
|
||||||
|
expect(Math.abs(identity.iconY - identity.nameY)).toBeLessThan(8);
|
||||||
|
await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible();
|
||||||
|
await page.getByRole('tab', { name: '二조' }).first().click();
|
||||||
|
await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true');
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||||
|
await persistScreenshot(page, 'tournament-mobile', testInfo.outputPath('tournament-bracket-mobile.webp'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await installFixture(page);
|
||||||
|
await page.goto('betting');
|
||||||
|
|
||||||
|
await expect(page.locator('.candidate-card')).toHaveCount(16);
|
||||||
|
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
|
||||||
|
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
|
||||||
|
await page.getByRole('tab', { name: '통솔전' }).click();
|
||||||
|
await expect(page.getByRole('tab', { name: '통솔전' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.ranking-table:visible thead')).toContainText('통 솔 전');
|
||||||
|
|
||||||
|
const identity = await page
|
||||||
|
.locator('.ranking-table:visible .general-identity')
|
||||||
|
.first()
|
||||||
|
.evaluate((element) => {
|
||||||
|
const icon = element.querySelector('img')!.getBoundingClientRect();
|
||||||
|
const name = element.querySelector<HTMLElement>('.general-identity-name')!.getBoundingClientRect();
|
||||||
|
return { iconRight: icon.right, nameLeft: name.left, iconY: icon.y, nameY: name.y };
|
||||||
|
});
|
||||||
|
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight);
|
||||||
|
expect(Math.abs(identity.iconY - identity.nameY)).toBeLessThan(8);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||||
|
await persistScreenshot(page, 'tournament-ranking-mobile', testInfo.outputPath('tournament-ranking-mobile.webp'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('desktop betting presents icon-and-name cards and all four rankings without document overflow', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await page.setViewportSize({ width: 1365, height: 900 });
|
||||||
|
await installFixture(page);
|
||||||
|
await page.goto('betting');
|
||||||
|
|
||||||
|
await expect(page.locator('.candidate-card')).toHaveCount(16);
|
||||||
|
await expect(page.locator('.ranking-table:visible')).toHaveCount(4);
|
||||||
|
const columns = await page
|
||||||
|
.locator('.candidate-grid')
|
||||||
|
.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length);
|
||||||
|
expect(columns).toBe(4);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1365);
|
||||||
|
await persistScreenshot(page, 'tournament-ranking-desktop', testInfo.outputPath('tournament-ranking-desktop.webp'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=500" />
|
||||||
<title>Sammo HiDCHe - Game</title>
|
<title>Sammo HiDCHe - Game</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-black text-white">
|
<body class="bg-black text-white">
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ body {
|
|||||||
min-width: 500px;
|
min-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* These redesigned identity/tournament screens own a true handheld layout. */
|
||||||
|
#app:has(.responsive-settings-page),
|
||||||
|
#app:has(#tournament-container),
|
||||||
|
#app:has(#tournament-betting-container) {
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
body:has(.battle-page),
|
body:has(.battle-page),
|
||||||
body:has(.chief-page),
|
body:has(.chief-page),
|
||||||
body:has(.global-page),
|
body:has(.global-page),
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
||||||
import type { CommandPatternEntry, CommandTable, ReservedCommandRow } from '../command/types';
|
import type {
|
||||||
|
CommandMapData,
|
||||||
|
CommandMapLayout,
|
||||||
|
CommandPatternEntry,
|
||||||
|
CommandTable,
|
||||||
|
ReservedCommandRow,
|
||||||
|
} from '../command/types';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
officerLevelText: string;
|
officerLevelText: string;
|
||||||
@@ -13,6 +19,8 @@ const props = defineProps<{
|
|||||||
generalId: number;
|
generalId: number;
|
||||||
officerLevel: number;
|
officerLevel: number;
|
||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
|
mapData?: CommandMapData | null;
|
||||||
|
mapLayout?: CommandMapLayout | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const commandRows = computed(() => props.rows.map((row) => ({ ...row, action: row.actionCode ?? row.action })));
|
const commandRows = computed(() => props.rows.map((row) => ({ ...row, action: row.actionCode ?? row.action })));
|
||||||
@@ -37,6 +45,8 @@ const emit = defineEmits<{
|
|||||||
:title="props.officerLevelText"
|
:title="props.officerLevelText"
|
||||||
:name="props.name"
|
:name="props.name"
|
||||||
:current-time="props.rows[0]?.time"
|
:current-time="props.rows[0]?.time"
|
||||||
|
:map-data="props.mapData"
|
||||||
|
:map-layout="props.mapLayout"
|
||||||
@reserve-bulk="emit('reserve-bulk', $event)"
|
@reserve-bulk="emit('reserve-bulk', $event)"
|
||||||
@shift="emit('shift', $event)"
|
@shift="emit('shift', $event)"
|
||||||
@repeat="emit('repeat', $event)"
|
@repeat="emit('repeat', $event)"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
|
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
|
||||||
import CommandArgumentForm from '../main/CommandArgumentForm.vue';
|
import CommandArgumentForm from '../main/CommandArgumentForm.vue';
|
||||||
import CommandSelectForm from '../main/CommandSelectForm.vue';
|
import CommandSelectForm from '../main/CommandSelectForm.vue';
|
||||||
|
import { commandArgumentPresentation } from './commandArgumentPresentation';
|
||||||
import DragSelect from './DragSelect.vue';
|
import DragSelect from './DragSelect.vue';
|
||||||
import RecruitmentCommandForm from './RecruitmentCommandForm.vue';
|
import RecruitmentCommandForm from './RecruitmentCommandForm.vue';
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +13,14 @@ import {
|
|||||||
normalizedSelection,
|
normalizedSelection,
|
||||||
selectStep,
|
selectStep,
|
||||||
} from './commandQueue';
|
} from './commandQueue';
|
||||||
import type { CommandAvailability, CommandPatternEntry, CommandTable, ReservedCommandRow } from './types';
|
import type {
|
||||||
|
CommandAvailability,
|
||||||
|
CommandMapData,
|
||||||
|
CommandMapLayout,
|
||||||
|
CommandPatternEntry,
|
||||||
|
CommandTable,
|
||||||
|
ReservedCommandRow,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -27,8 +35,19 @@ const props = withDefaults(
|
|||||||
title?: string;
|
title?: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
currentTime?: string;
|
currentTime?: string;
|
||||||
|
mapData?: CommandMapData | null;
|
||||||
|
mapLayout?: CommandMapLayout | null;
|
||||||
}>(),
|
}>(),
|
||||||
{ maxPushTurn: 6, compact: false, mobile: false, title: '', name: null, currentTime: '--:--' }
|
{
|
||||||
|
maxPushTurn: 6,
|
||||||
|
compact: false,
|
||||||
|
mobile: false,
|
||||||
|
title: '',
|
||||||
|
name: null,
|
||||||
|
currentTime: '--:--',
|
||||||
|
mapData: null,
|
||||||
|
mapLayout: null,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -153,6 +172,14 @@ const closePicker = () => {
|
|||||||
quickTarget.value = null;
|
quickTarget.value = null;
|
||||||
selectedCommand.value = null;
|
selectedCommand.value = null;
|
||||||
};
|
};
|
||||||
|
const togglePicker = (turnIndex?: number) => {
|
||||||
|
const target = turnIndex ?? null;
|
||||||
|
if (pickerOpen.value && quickTarget.value === target) {
|
||||||
|
closePicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openPicker(turnIndex);
|
||||||
|
};
|
||||||
const selectCommand = (commandKey: string) => {
|
const selectCommand = (commandKey: string) => {
|
||||||
const command = props.commandTable?.[props.scope]
|
const command = props.commandTable?.[props.scope]
|
||||||
.flatMap((group) => group.values)
|
.flatMap((group) => group.values)
|
||||||
@@ -242,7 +269,15 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
<template>
|
<template>
|
||||||
<article
|
<article
|
||||||
class="reserved-command-editor"
|
class="reserved-command-editor"
|
||||||
:class="{ compact: props.compact, mobile: props.mobile, 'edit-mode': editMode, 'picker-open': pickerOpen }"
|
:class="{
|
||||||
|
compact: props.compact,
|
||||||
|
mobile: props.mobile,
|
||||||
|
'edit-mode': editMode,
|
||||||
|
'picker-open': pickerOpen,
|
||||||
|
'argument-expanded': Boolean(
|
||||||
|
selectedCommand?.reqArg && commandArgumentPresentation(selectedCommand.key).lines.length
|
||||||
|
),
|
||||||
|
}"
|
||||||
:data-command-scope="props.scope"
|
:data-command-scope="props.scope"
|
||||||
>
|
>
|
||||||
<header v-if="props.compact && !props.mobile" class="identity legacy-bg1">
|
<header v-if="props.compact && !props.mobile" class="identity legacy-bg1">
|
||||||
@@ -309,6 +344,7 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
>
|
>
|
||||||
짝수턴
|
짝수턴
|
||||||
</button>
|
</button>
|
||||||
|
<hr class="menu-divider" />
|
||||||
<template v-for="step in [3, 4, 5, 6, 7]" :key="step">
|
<template v-for="step in [3, 4, 5, 6, 7]" :key="step">
|
||||||
<small>{{ step }}턴 간격</small>
|
<small>{{ step }}턴 간격</small>
|
||||||
<div class="step-buttons">
|
<div class="step-buttons">
|
||||||
@@ -433,6 +469,7 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
>
|
>
|
||||||
붙여넣기
|
붙여넣기
|
||||||
</button>
|
</button>
|
||||||
|
<hr class="menu-divider" />
|
||||||
<button
|
<button
|
||||||
@click="
|
@click="
|
||||||
textCopy();
|
textCopy();
|
||||||
@@ -441,6 +478,7 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
>
|
>
|
||||||
텍스트 복사
|
텍스트 복사
|
||||||
</button>
|
</button>
|
||||||
|
<hr class="menu-divider" />
|
||||||
<button
|
<button
|
||||||
@click="
|
@click="
|
||||||
saveTemplate();
|
saveTemplate();
|
||||||
@@ -457,6 +495,7 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
>
|
>
|
||||||
반복하기
|
반복하기
|
||||||
</button>
|
</button>
|
||||||
|
<hr class="menu-divider" />
|
||||||
<button
|
<button
|
||||||
@click="
|
@click="
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -483,7 +522,7 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<button type="button" class="select-command" @click="openPicker()">명령 선택 ▾</button>
|
<button type="button" class="select-command" @click="togglePicker()">명령 선택 ▾</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="queue-area">
|
<div class="queue-area">
|
||||||
@@ -546,7 +585,7 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
:key="row.index"
|
:key="row.index"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="`${row.index + 1}턴 명령 입력`"
|
:aria-label="`${row.index + 1}턴 명령 입력`"
|
||||||
@click="openPicker(row.index)"
|
@click="togglePicker(row.index)"
|
||||||
>
|
>
|
||||||
✎
|
✎
|
||||||
</button>
|
</button>
|
||||||
@@ -606,6 +645,8 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
:command-key="selectedCommand.key"
|
:command-key="selectedCommand.key"
|
||||||
:fields="selectedCommand.inputFields"
|
:fields="selectedCommand.inputFields"
|
||||||
:options="props.commandTable.inputOptions"
|
:options="props.commandTable.inputOptions"
|
||||||
|
:map-data="props.mapData"
|
||||||
|
:map-layout="props.mapLayout"
|
||||||
@update:args="commandArgs = $event"
|
@update:args="commandArgs = $event"
|
||||||
@update:valid="commandArgsValid = $event"
|
@update:valid="commandArgsValid = $event"
|
||||||
/>
|
/>
|
||||||
@@ -725,6 +766,14 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
color: #bbb;
|
color: #bbb;
|
||||||
}
|
}
|
||||||
|
.menu-divider {
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
margin: 4px 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #444;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
.step-buttons,
|
.step-buttons,
|
||||||
.template-row {
|
.template-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -933,6 +982,11 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1025px) {
|
@media (min-width: 1025px) {
|
||||||
|
.argument-expanded:not(.compact) .command-picker {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
width: 700px;
|
||||||
|
}
|
||||||
.compact:not(.mobile) .command-picker {
|
.compact:not(.mobile) .command-picker {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -941,6 +995,13 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
left: calc(50% - 476px);
|
left: calc(50% - 476px);
|
||||||
width: 238px;
|
width: 238px;
|
||||||
}
|
}
|
||||||
|
.compact.argument-expanded:not(.mobile) .command-picker {
|
||||||
|
left: calc(50% - 350px);
|
||||||
|
width: 700px;
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 104px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
.compact:not(.mobile) .command-picker.recruitment-picker {
|
.compact:not(.mobile) .command-picker.recruitment-picker {
|
||||||
top: 76px;
|
top: 76px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -984,12 +1045,25 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
width: 370px;
|
width: 370px;
|
||||||
height: 327px;
|
height: 327px;
|
||||||
}
|
}
|
||||||
|
.mobile.compact.argument-expanded .command-picker {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
margin-top: -330px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
.mobile.compact .command-picker.recruitment-picker {
|
.mobile.compact .command-picker.recruitment-picker {
|
||||||
|
position: fixed;
|
||||||
top: 76px;
|
top: 76px;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 500px;
|
width: 500px;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: calc(100vh - 82px);
|
max-height: calc(100vh - 82px);
|
||||||
|
margin-top: 0;
|
||||||
|
overflow: auto;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
.mobile.compact .advanced-actions {
|
.mobile.compact .advanced-actions {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
export type CommandArgumentPresentation = {
|
||||||
|
lines: string[];
|
||||||
|
mapTarget?: 'city' | 'nation';
|
||||||
|
};
|
||||||
|
|
||||||
|
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
|
||||||
|
const nationTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'nation' });
|
||||||
|
|
||||||
|
// Ref hwe/ts/processing의 명령별 안내를 예약 명령 옵션창에 맞게 옮긴다.
|
||||||
|
// 징병/모병은 별도 이관 범위이므로 이 표에 넣지 않는다.
|
||||||
|
const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
|
||||||
|
che_강행: cityTarget(['선택한 도시로 강행합니다.', '최대 3칸 안의 도시만 선택할 수 있습니다.']),
|
||||||
|
che_이동: cityTarget(['선택한 도시로 이동합니다.', '인접한 도시로만 이동할 수 있습니다.']),
|
||||||
|
che_출병: cityTarget([
|
||||||
|
'선택한 도시를 향해 침공합니다.',
|
||||||
|
'침공 경로에 적군 도시가 있으면 그 도시에서 전투를 벌입니다.',
|
||||||
|
]),
|
||||||
|
che_첩보: cityTarget(['선택한 도시에 첩보를 실행합니다.', '인접 도시에서는 더 많은 정보를 얻습니다.']),
|
||||||
|
che_화계: cityTarget(['선택한 도시에 화계를 실행합니다.']),
|
||||||
|
che_탈취: cityTarget(['선택한 도시에 탈취를 실행합니다.']),
|
||||||
|
che_파괴: cityTarget(['선택한 도시에 파괴를 실행합니다.']),
|
||||||
|
che_선동: cityTarget(['선택한 도시에 선동을 실행합니다.']),
|
||||||
|
che_수몰: cityTarget(['선택한 도시에 수몰을 발동합니다.', '전쟁 중인 상대국 도시만 대상이 됩니다.']),
|
||||||
|
che_백성동원: cityTarget(['선택한 도시에 백성을 동원해 성벽을 쌓습니다.', '아국 도시만 대상이 됩니다.']),
|
||||||
|
che_천도: cityTarget([
|
||||||
|
'선택한 도시로 수도를 옮깁니다.',
|
||||||
|
'현재 수도에서 연결된 도시만 가능하며 1 + 2 × 거리만큼의 턴이 필요합니다.',
|
||||||
|
]),
|
||||||
|
che_허보: cityTarget(['선택한 도시에 허보를 발동합니다.', '선포 또는 전쟁 중인 상대국 도시만 대상이 됩니다.']),
|
||||||
|
che_초토화: cityTarget([
|
||||||
|
'선택한 도시를 초토화해 공백지로 만듭니다.',
|
||||||
|
'인구와 내정 상태에 따라 국고를 확보하고, 수뇌 명성과 모든 장수의 배신 수치에 영향을 줍니다.',
|
||||||
|
]),
|
||||||
|
cr_인구이동: cityTarget(['현재 도시의 인구를 선택한 인접 도시로 이동합니다.']),
|
||||||
|
che_발령: cityTarget(['선택한 도시로 아국 장수를 발령합니다.', '아국 도시만 대상이 됩니다.']),
|
||||||
|
|
||||||
|
che_선전포고: nationTarget([
|
||||||
|
'선택한 국가에 선전포고합니다.',
|
||||||
|
'고립되지 않은 아국 도시와 인접한 국가에만 가능하며 초반 제한의 영향을 받습니다.',
|
||||||
|
]),
|
||||||
|
che_급습: nationTarget(['선택한 국가에 급습을 발동합니다.', '선포 또는 전쟁 중인 상대국만 대상이 됩니다.']),
|
||||||
|
che_불가침파기제의: nationTarget(['불가침 중인 국가에 조약 파기를 제의합니다.']),
|
||||||
|
che_이호경식: nationTarget(['선택한 국가에 이호경식을 발동합니다.', '선포 또는 전쟁 중인 상대국만 대상이 됩니다.']),
|
||||||
|
che_종전제의: nationTarget(['전쟁 중인 국가에 종전을 제의합니다.']),
|
||||||
|
che_불가침제의: nationTarget([
|
||||||
|
'선택한 국가에 불가침을 제의합니다.',
|
||||||
|
'불가침 기한 다음 달부터 다시 선전포고할 수 있습니다.',
|
||||||
|
]),
|
||||||
|
che_피장파장: nationTarget([
|
||||||
|
'선택한 국가가 지정한 전략을 일정 턴 동안 사용하지 못하게 합니다.',
|
||||||
|
'아국에도 지정 전략의 재사용 제한이 생깁니다.',
|
||||||
|
]),
|
||||||
|
che_물자원조: nationTarget(['타국에 금과 쌀을 원조합니다.', '국가 작위에 따라 보낼 수 있는 금액이 제한됩니다.']),
|
||||||
|
|
||||||
|
che_증여: { lines: ['자신의 금이나 쌀을 선택한 장수에게 증여합니다.'] },
|
||||||
|
che_헌납: { lines: ['자신의 금이나 쌀을 국가 재산으로 헌납합니다.'] },
|
||||||
|
che_군량매매: { lines: ['자신의 군량을 사거나 팝니다.'] },
|
||||||
|
che_몰수: { lines: ['선택한 장수의 금이나 쌀을 몰수해 국가 재산으로 귀속합니다.'] },
|
||||||
|
che_포상: { lines: ['국고에서 선택한 장수에게 금이나 쌀을 지급합니다.'] },
|
||||||
|
che_부대탈퇴지시: { lines: ['선택한 장수에게 부대 탈퇴를 지시합니다.', '현재 부대원인 장수만 대상이 됩니다.'] },
|
||||||
|
che_등용: { lines: ['재야 또는 타국 장수에게 등용 서신을 보냅니다.', '서신은 개인 메시지로 전달됩니다.'] },
|
||||||
|
che_선양: { lines: ['군주의 자리를 선택한 아국 장수에게 물려줍니다.'] },
|
||||||
|
che_임관: {
|
||||||
|
lines: [
|
||||||
|
'선택한 국가에 임관하고 군주의 위치로 이동합니다.',
|
||||||
|
'이미 임관하거나 등용되었던 국가는 선택할 수 없습니다.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
che_장수대상임관: {
|
||||||
|
lines: ['선택한 장수를 따라 그 장수의 국가에 임관하고 군주의 위치로 이동합니다.'],
|
||||||
|
},
|
||||||
|
che_숙련전환: {
|
||||||
|
lines: ['선택한 병과 숙련을 40% 줄이고, 줄어든 숙련의 90%를 다른 병과 숙련으로 전환합니다.'],
|
||||||
|
},
|
||||||
|
che_장비매매: { lines: ['장비를 구입하거나 매각합니다.', '가격과 요구 치안, 장비 효과를 확인한 뒤 선택하세요.'] },
|
||||||
|
che_건국: {
|
||||||
|
lines: ['현재 중·소도시에서 나라를 세웁니다.', '국가 성향별 장단점을 확인한 뒤 국명과 색상을 정하세요.'],
|
||||||
|
},
|
||||||
|
che_무작위건국: {
|
||||||
|
lines: ['무작위 공백 중·소도시에서 나라를 세웁니다.', '국가 성향별 장단점을 확인한 뒤 국명과 색상을 정하세요.'],
|
||||||
|
},
|
||||||
|
cr_건국: { lines: ['현재 도시에서 규모 제한 없이 나라를 세웁니다.', '국가 성향별 장단점을 확인하세요.'] },
|
||||||
|
che_국기변경: { lines: ['국기의 색상을 변경합니다.', '이 명령은 한 번만 실행할 수 있습니다.'] },
|
||||||
|
che_국호변경: { lines: ['국가 이름을 변경합니다.', '황제가 된 뒤 한 번만 실행할 수 있습니다.'] },
|
||||||
|
che_등용수락: { lines: ['도착한 등용 제의에 응할 행동을 선택합니다.'] },
|
||||||
|
che_NPC능동: { lines: ['NPC 장수의 능동 행동 방식을 선택합니다.'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation =>
|
||||||
|
PRESENTATIONS[commandKey] ?? { lines: [] };
|
||||||
|
|
||||||
|
export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS);
|
||||||
@@ -1,4 +1,36 @@
|
|||||||
export type CommandOption = { value: string | number; label: string; color?: string };
|
export type CommandOption = {
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
color?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandMapData = {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
startYear: number;
|
||||||
|
techLevelLimit?: { maxLevel: number; initialLevel: number; increaseYears: number };
|
||||||
|
cityList: [number, number, number, number, number, number][];
|
||||||
|
nationList: [number, string, string, number][];
|
||||||
|
myCity?: number | null;
|
||||||
|
myNation?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandMapLayout = {
|
||||||
|
mapName: string;
|
||||||
|
cityList: Array<{ id: number; name: string; level: number; region: number; x: number; y: number; path: number[] }>;
|
||||||
|
regionMap: Record<number, string>;
|
||||||
|
levelMap: Record<number, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandInputContext = {
|
||||||
|
actorGold: number;
|
||||||
|
actorRice: number;
|
||||||
|
citySecurity?: number;
|
||||||
|
nationGold?: number;
|
||||||
|
nationRice?: number;
|
||||||
|
nationLevel?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type CommandInputField = {
|
export type CommandInputField = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -69,6 +101,7 @@ export type CommandTable = {
|
|||||||
colors: CommandOption[];
|
colors: CommandOption[];
|
||||||
items: Record<string, CommandOption[]>;
|
items: Record<string, CommandOption[]>;
|
||||||
recruitment: RecruitmentInfo | null;
|
recruitment: RecruitmentInfo | null;
|
||||||
|
context?: CommandInputContext;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, watch } from 'vue';
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import MapViewer from './MapViewer.vue';
|
||||||
|
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
|
||||||
|
import type {
|
||||||
|
CommandInputContext,
|
||||||
|
CommandInputField,
|
||||||
|
CommandMapData,
|
||||||
|
CommandMapLayout,
|
||||||
|
CommandOption,
|
||||||
|
CommandTable,
|
||||||
|
} from '../command/types';
|
||||||
|
|
||||||
type OptionValue = string | number;
|
type CommandInputOptions = CommandTable['inputOptions'];
|
||||||
interface CommandOption {
|
|
||||||
value: OptionValue;
|
|
||||||
label: string;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
interface CommandInputField {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
kind: 'text' | 'number' | 'boolean' | 'select' | 'numberTuple' | 'hidden';
|
|
||||||
required: boolean;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
step?: number;
|
|
||||||
constValue?: OptionValue;
|
|
||||||
options?: CommandOption[];
|
|
||||||
optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
|
|
||||||
tupleLabels?: string[];
|
|
||||||
}
|
|
||||||
interface CommandInputOptions {
|
|
||||||
cities: CommandOption[];
|
|
||||||
nations: CommandOption[];
|
|
||||||
generals: CommandOption[];
|
|
||||||
crewTypes: CommandOption[];
|
|
||||||
armTypes: CommandOption[];
|
|
||||||
nationTypes: CommandOption[];
|
|
||||||
colors: CommandOption[];
|
|
||||||
items: Record<string, CommandOption[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
commandKey: string;
|
commandKey: string;
|
||||||
fields: CommandInputField[];
|
fields: CommandInputField[];
|
||||||
options: CommandInputOptions;
|
options: CommandInputOptions;
|
||||||
|
mapData?: CommandMapData | null;
|
||||||
|
mapLayout?: CommandMapLayout | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -43,6 +27,8 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const values = reactive<Record<string, unknown>>({});
|
const values = reactive<Record<string, unknown>>({});
|
||||||
|
const presentation = computed(() => commandArgumentPresentation(props.commandKey));
|
||||||
|
const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !== 'hidden'));
|
||||||
|
|
||||||
const optionsFor = (field: CommandInputField): CommandOption[] => {
|
const optionsFor = (field: CommandInputField): CommandOption[] => {
|
||||||
if (field.options) return field.options;
|
if (field.options) return field.options;
|
||||||
@@ -58,7 +44,16 @@ const defaultValue = (field: CommandInputField): unknown => {
|
|||||||
if (field.kind === 'boolean') return true;
|
if (field.kind === 'boolean') return true;
|
||||||
if (field.kind === 'numberTuple') return [field.min ?? 0, field.min ?? 0];
|
if (field.kind === 'numberTuple') return [field.min ?? 0, field.min ?? 0];
|
||||||
if (field.kind === 'number') return field.min ?? 0;
|
if (field.kind === 'number') return field.min ?? 0;
|
||||||
if (field.kind === 'select') return optionsFor(field)[0]?.value ?? '';
|
if (field.kind === 'select') {
|
||||||
|
const options = optionsFor(field);
|
||||||
|
const mapDefault =
|
||||||
|
field.optionSource === 'cities' && (field.key === 'destCityId' || field.key === 'destCityID')
|
||||||
|
? props.mapData?.myCity
|
||||||
|
: field.optionSource === 'nations' && field.key === 'destNationId'
|
||||||
|
? props.mapData?.myNation
|
||||||
|
: null;
|
||||||
|
return options.find((option) => option.value === mapDefault)?.value ?? options[0]?.value ?? '';
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,6 +73,129 @@ const setSelectValue = (field: CommandInputField, rawValue: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedOptionFor = (field: CommandInputField): CommandOption | undefined =>
|
||||||
|
optionsFor(field).find((entry) => entry.value === values[field.key]);
|
||||||
|
|
||||||
|
const cityTargetField = computed(() =>
|
||||||
|
props.fields.find(
|
||||||
|
(field) =>
|
||||||
|
field.kind === 'select' &&
|
||||||
|
field.optionSource === 'cities' &&
|
||||||
|
(field.key === 'destCityId' || field.key === 'destCityID')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const nationTargetField = computed(() =>
|
||||||
|
props.fields.find(
|
||||||
|
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const showMap = computed(
|
||||||
|
() =>
|
||||||
|
Boolean(props.mapData && props.mapLayout) &&
|
||||||
|
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
|
||||||
|
(presentation.value.mapTarget === 'nation' && nationTargetField.value))
|
||||||
|
);
|
||||||
|
const mapSelectedCityId = computed<number | null>(() => {
|
||||||
|
if (!props.mapData) return null;
|
||||||
|
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
|
||||||
|
const value = values[cityTargetField.value.key];
|
||||||
|
return typeof value === 'number' ? value : null;
|
||||||
|
}
|
||||||
|
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||||
|
const value = values[nationTargetField.value.key];
|
||||||
|
if (typeof value !== 'number') return null;
|
||||||
|
return props.mapData.cityList.find((entry) => entry[3] === value)?.[0] ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const distanceFromMyCity = (destination: number): number | null => {
|
||||||
|
const start = props.mapData?.myCity;
|
||||||
|
if (!start || !props.mapLayout) return null;
|
||||||
|
if (start === destination) return 0;
|
||||||
|
const paths = new Map(props.mapLayout.cityList.map((city) => [city.id, city.path]));
|
||||||
|
const visited = new Set<number>([start]);
|
||||||
|
let frontier = [start];
|
||||||
|
for (let distance = 1; frontier.length; distance += 1) {
|
||||||
|
const next: number[] = [];
|
||||||
|
for (const cityId of frontier) {
|
||||||
|
for (const adjacentId of paths.get(cityId) ?? []) {
|
||||||
|
if (visited.has(adjacentId)) continue;
|
||||||
|
if (adjacentId === destination) return distance;
|
||||||
|
visited.add(adjacentId);
|
||||||
|
next.push(adjacentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapTargetSummary = computed(() => {
|
||||||
|
if (!props.mapData || !props.mapLayout) return '';
|
||||||
|
if (presentation.value.mapTarget === 'city' && mapSelectedCityId.value) {
|
||||||
|
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
|
||||||
|
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
|
||||||
|
if (!city) return '';
|
||||||
|
const nation = props.mapData.nationList.find((entry) => entry[0] === dynamic?.[3]);
|
||||||
|
const distance = distanceFromMyCity(city.id);
|
||||||
|
return [
|
||||||
|
city.name,
|
||||||
|
nation?.[1] ?? '무주',
|
||||||
|
props.mapLayout.regionMap[dynamic?.[4] ?? city.region],
|
||||||
|
props.mapLayout.levelMap[dynamic?.[1] ?? city.level],
|
||||||
|
distance === null ? null : `현재 도시에서 ${distance}칸`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||||
|
const value = values[nationTargetField.value.key];
|
||||||
|
if (typeof value !== 'number') return '';
|
||||||
|
const nation = props.mapData.nationList.find((entry) => entry[0] === value);
|
||||||
|
if (!nation) return '';
|
||||||
|
const capital = props.mapLayout.cityList.find((entry) => entry.id === nation[3]);
|
||||||
|
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
|
||||||
|
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}개`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectMapCity = (cityId: number) => {
|
||||||
|
if (!props.mapData) return;
|
||||||
|
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
|
||||||
|
setSelectValue(cityTargetField.value, String(cityId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||||
|
const nationId = props.mapData.cityList.find((entry) => entry[0] === cityId)?.[3];
|
||||||
|
if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resourceSummary = computed(() => {
|
||||||
|
const context: CommandInputContext | undefined = props.options.context;
|
||||||
|
if (!context) return [];
|
||||||
|
const result: string[] = [];
|
||||||
|
const usesActorResources = new Set(['che_증여', 'che_헌납', 'che_군량매매', 'che_장비매매']);
|
||||||
|
const usesNationResources = new Set(['che_몰수', 'che_포상', 'che_물자원조']);
|
||||||
|
if (usesActorResources.has(props.commandKey)) {
|
||||||
|
result.push(
|
||||||
|
`현재 자금 ${context.actorGold.toLocaleString()}`,
|
||||||
|
`현재 군량 ${context.actorRice.toLocaleString()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (props.commandKey === 'che_장비매매' && context.citySecurity !== undefined) {
|
||||||
|
result.push(`현재 도시 치안 ${context.citySecurity.toLocaleString()}`);
|
||||||
|
}
|
||||||
|
if (usesNationResources.has(props.commandKey)) {
|
||||||
|
if (context.nationGold !== undefined) result.push(`국고 ${context.nationGold.toLocaleString()}`);
|
||||||
|
if (context.nationRice !== undefined) result.push(`국가 군량 ${context.nationRice.toLocaleString()}`);
|
||||||
|
if (context.nationLevel !== undefined) result.push(`국가 작위 ${context.nationLevel}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
const setTupleValue = (field: CommandInputField, index: number, rawValue: string) => {
|
const setTupleValue = (field: CommandInputField, index: number, rawValue: string) => {
|
||||||
const tuple = Array.isArray(values[field.key]) ? [...(values[field.key] as unknown[])] : [0, 0];
|
const tuple = Array.isArray(values[field.key]) ? [...(values[field.key] as unknown[])] : [0, 0];
|
||||||
tuple[index] = Number(rawValue);
|
tuple[index] = Number(rawValue);
|
||||||
@@ -89,17 +207,32 @@ const isValid = computed(() =>
|
|||||||
const value = values[field.key];
|
const value = values[field.key];
|
||||||
if (field.kind === 'text') {
|
if (field.kind === 'text') {
|
||||||
const length = typeof value === 'string' ? value.trim().length : 0;
|
const length = typeof value === 'string' ? value.trim().length : 0;
|
||||||
return (!field.required || length > 0) && (field.min === undefined || length >= field.min) &&
|
return (
|
||||||
(field.max === undefined || length <= field.max);
|
(!field.required || length > 0) &&
|
||||||
|
(field.min === undefined || length >= field.min) &&
|
||||||
|
(field.max === undefined || length <= field.max)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (field.kind === 'number') {
|
if (field.kind === 'number') {
|
||||||
return typeof value === 'number' && Number.isFinite(value) &&
|
return (
|
||||||
(field.min === undefined || value >= field.min) && (field.max === undefined || value <= field.max);
|
typeof value === 'number' &&
|
||||||
|
Number.isFinite(value) &&
|
||||||
|
(field.min === undefined || value >= field.min) &&
|
||||||
|
(field.max === undefined || value <= field.max)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (field.kind === 'numberTuple') {
|
if (field.kind === 'numberTuple') {
|
||||||
return Array.isArray(value) && value.length === 2 &&
|
return (
|
||||||
value.every((entry) => typeof entry === 'number' && Number.isFinite(entry) &&
|
Array.isArray(value) &&
|
||||||
(field.min === undefined || entry >= field.min) && (field.max === undefined || entry <= field.max));
|
value.length === 2 &&
|
||||||
|
value.every(
|
||||||
|
(entry) =>
|
||||||
|
typeof entry === 'number' &&
|
||||||
|
Number.isFinite(entry) &&
|
||||||
|
(field.min === undefined || entry >= field.min) &&
|
||||||
|
(field.max === undefined || entry <= field.max)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (field.kind === 'select') return optionsFor(field).some((option) => option.value === value);
|
if (field.kind === 'select') return optionsFor(field).some((option) => option.value === value);
|
||||||
return value !== undefined;
|
return value !== undefined;
|
||||||
@@ -119,11 +252,28 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="props.fields.length" class="command-argument-form" data-testid="command-argument-form">
|
<div v-if="props.fields.length" class="command-argument-form" data-testid="command-argument-form">
|
||||||
<div
|
<div v-if="showMap" class="command-map" data-testid="command-argument-map">
|
||||||
v-for="field in props.fields.filter((entry) => entry.kind !== 'hidden')"
|
<MapViewer
|
||||||
:key="field.key"
|
:map-data="props.mapData ?? null"
|
||||||
class="argument-row"
|
:map-layout="props.mapLayout ?? null"
|
||||||
>
|
:loading="false"
|
||||||
|
:selected-city-id="mapSelectedCityId"
|
||||||
|
:detail-mode="false"
|
||||||
|
:fit-container="true"
|
||||||
|
@select-city="selectMapCity"
|
||||||
|
/>
|
||||||
|
<small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
|
||||||
|
<div v-if="mapTargetSummary" class="map-target-summary" data-testid="command-map-target-summary">
|
||||||
|
{{ mapTargetSummary }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="presentation.lines.length" class="command-guidance" data-testid="command-argument-guidance">
|
||||||
|
<div v-for="line in presentation.lines" :key="line">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="resourceSummary.length" class="resource-summary" data-testid="command-resource-summary">
|
||||||
|
<span v-for="entry in resourceSummary" :key="entry">{{ entry }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="field in visibleFields" :key="field.key" class="argument-row">
|
||||||
<label :for="`command-arg-${field.key}`">{{ field.label }}</label>
|
<label :for="`command-arg-${field.key}`">{{ field.label }}</label>
|
||||||
<input
|
<input
|
||||||
v-if="field.kind === 'text'"
|
v-if="field.kind === 'text'"
|
||||||
@@ -182,6 +332,21 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
field.kind === 'select' &&
|
||||||
|
(selectedOptionFor(field)?.description || selectedOptionFor(field)?.color)
|
||||||
|
"
|
||||||
|
class="option-detail"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="selectedOptionFor(field)?.color"
|
||||||
|
class="option-color"
|
||||||
|
:style="{ backgroundColor: selectedOptionFor(field)?.color }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{{ selectedOptionFor(field)?.description }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
|
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,6 +358,43 @@ watch(
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.command-map {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-map small {
|
||||||
|
display: block;
|
||||||
|
padding: 5px 8px;
|
||||||
|
color: rgba(232, 221, 196, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-target-summary {
|
||||||
|
padding: 0 8px 6px;
|
||||||
|
color: #f1d89a;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-guidance {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid rgba(201, 164, 90, 0.35);
|
||||||
|
background: #191919;
|
||||||
|
color: #eee;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px 14px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid rgba(201, 164, 90, 0.25);
|
||||||
|
color: #f1d89a;
|
||||||
|
}
|
||||||
|
|
||||||
.argument-row {
|
.argument-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(76px, 0.36fr) 1fr;
|
grid-template-columns: minmax(76px, 0.36fr) 1fr;
|
||||||
@@ -200,6 +402,23 @@ watch(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.option-detail {
|
||||||
|
grid-column: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 6px 6px 0;
|
||||||
|
color: rgba(232, 221, 196, 0.74);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-color {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 18px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
.argument-row:nth-child(odd) {
|
.argument-row:nth-child(odd) {
|
||||||
background: rgba(255, 255, 255, 0.035);
|
background: rgba(255, 255, 255, 0.035);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } 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 type { CommandPatternEntry, CommandTable, ReservedCommandRow } from '../command/types';
|
import type {
|
||||||
|
CommandMapData,
|
||||||
|
CommandMapLayout,
|
||||||
|
CommandPatternEntry,
|
||||||
|
CommandTable,
|
||||||
|
ReservedCommandRow,
|
||||||
|
} from '../command/types';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
commandTable: CommandTable | null;
|
commandTable: CommandTable | null;
|
||||||
@@ -14,6 +20,8 @@ const props = defineProps<{
|
|||||||
turnTermMinutes?: number;
|
turnTermMinutes?: number;
|
||||||
autorunLimit?: number | null;
|
autorunLimit?: number | null;
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
|
mapData?: CommandMapData | null;
|
||||||
|
mapLayout?: CommandMapLayout | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -63,6 +71,8 @@ const rows = computed<ReservedCommandRow[]>(() => {
|
|||||||
:loading="props.loading"
|
:loading="props.loading"
|
||||||
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
|
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
|
||||||
:current-time="rows[0]?.time"
|
:current-time="rows[0]?.time"
|
||||||
|
:map-data="props.mapData"
|
||||||
|
:map-layout="props.mapLayout"
|
||||||
@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)"
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
||||||
import { formatSeoulHourMinute } from '../../utils/legacyDateTime';
|
import { formatSeoulHourMinute } from '../../utils/legacyDateTime';
|
||||||
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
||||||
|
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
||||||
|
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
||||||
|
|
||||||
interface GeneralStats {
|
interface GeneralStats {
|
||||||
leadership: number;
|
leadership: number;
|
||||||
@@ -19,9 +22,18 @@ interface GeneralProgression {
|
|||||||
statUpgradeLimit?: number;
|
statUpgradeLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ItemDisplayNames {
|
||||||
|
horse?: string | null;
|
||||||
|
weapon?: string | null;
|
||||||
|
book?: string | null;
|
||||||
|
item?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface GeneralInfo {
|
interface GeneralInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
picture?: string | null;
|
||||||
|
imageServer?: number | null;
|
||||||
npcState: number;
|
npcState: number;
|
||||||
officerLevel: number;
|
officerLevel: number;
|
||||||
officerLevelText: string;
|
officerLevelText: string;
|
||||||
@@ -35,17 +47,36 @@ interface GeneralInfo {
|
|||||||
experience: number;
|
experience: number;
|
||||||
dedication: number;
|
dedication: number;
|
||||||
age?: number;
|
age?: number;
|
||||||
turnTime?: string;
|
turnTime?: string | null;
|
||||||
|
troopId?: number;
|
||||||
crewTypeId?: number;
|
crewTypeId?: number;
|
||||||
crewTypeName?: string;
|
crewTypeName?: string;
|
||||||
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
||||||
progression?: GeneralProgression;
|
progression?: GeneralProgression;
|
||||||
|
itemNames?: ItemDisplayNames;
|
||||||
|
equipmentNames?: ItemDisplayNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
general: GeneralInfo | null;
|
defineProps<{
|
||||||
loading: boolean;
|
general: GeneralInfo | null;
|
||||||
}>();
|
loading: boolean;
|
||||||
|
nationColor?: string | null;
|
||||||
|
defenceText?: string | null;
|
||||||
|
killTurn?: number | null;
|
||||||
|
remainingMinutes?: number | null;
|
||||||
|
troopText?: string | null;
|
||||||
|
penaltyText?: string | number | null;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
nationColor: '#173d27',
|
||||||
|
defenceText: null,
|
||||||
|
killTurn: null,
|
||||||
|
remainingMinutes: null,
|
||||||
|
troopText: null,
|
||||||
|
penaltyText: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const statRows = computed(() => {
|
const statRows = computed(() => {
|
||||||
const general = props.general;
|
const general = props.general;
|
||||||
@@ -72,137 +103,320 @@ const statRows = computed(() => {
|
|||||||
const experiencePercent = computed(() =>
|
const experiencePercent = computed(() =>
|
||||||
legacyExperiencePercent(props.general?.experience ?? 0, props.general?.progression?.experienceLevel ?? 0)
|
legacyExperiencePercent(props.general?.experience ?? 0, props.general?.progression?.experienceLevel ?? 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const itemNames = computed<ItemDisplayNames>(() => props.general?.itemNames ?? props.general?.equipmentNames ?? {});
|
||||||
|
|
||||||
|
const generalIconBackground = computed(() => resolveGeneralIconBackgroundImage(props.general ?? {}));
|
||||||
|
|
||||||
|
const crewTypeIconBackground = computed(() => {
|
||||||
|
const crewTypeId = props.general?.crewTypeId;
|
||||||
|
if (crewTypeId === undefined || !Number.isFinite(crewTypeId)) {
|
||||||
|
return `url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
|
||||||
|
}
|
||||||
|
const crewTypeUrl = `${configuredGameAssetUrl()}/crewtype${Math.trunc(crewTypeId)}.png`;
|
||||||
|
return `url(${JSON.stringify(crewTypeUrl)}), url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const injuryInfo = computed(() => {
|
||||||
|
const injury = props.general?.injury ?? 0;
|
||||||
|
if (injury > 60) return { text: '위독', color: '#ff4d4f' };
|
||||||
|
if (injury > 40) return { text: '심각', color: '#ff00ff' };
|
||||||
|
if (injury > 20) return { text: '중상', color: '#ff9f1a' };
|
||||||
|
if (injury > 0) return { text: '경상', color: '#ffff00' };
|
||||||
|
return { text: '건강', color: '#ffffff' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBrightColor = (color: string): boolean => {
|
||||||
|
const normalized = /^#[0-9a-f]{6}$/iu.test(color) ? color.slice(1) : '173d27';
|
||||||
|
const red = Number.parseInt(normalized.slice(0, 2), 16);
|
||||||
|
const green = Number.parseInt(normalized.slice(2, 4), 16);
|
||||||
|
const blue = Number.parseInt(normalized.slice(4, 6), 16);
|
||||||
|
return (red * 299 + green * 587 + blue * 114) / 1000 >= 150;
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleStyle = computed(() => {
|
||||||
|
const backgroundColor = props.nationColor || '#173d27';
|
||||||
|
return {
|
||||||
|
backgroundColor,
|
||||||
|
color: isBrightColor(backgroundColor) ? '#000000' : '#ffffff',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const ageColor = computed(() => {
|
||||||
|
const age = props.general?.age;
|
||||||
|
if (age === undefined) return '#ffffff';
|
||||||
|
if (age < 53) return '#32cd32';
|
||||||
|
if (age < 70) return '#ffff00';
|
||||||
|
return '#ff4d4f';
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayTroop = computed(() => props.troopText ?? (props.general?.troopId ? String(props.general.troopId) : '-'));
|
||||||
|
const displayPenalty = computed(() => {
|
||||||
|
const penalty = props.penaltyText ?? '-';
|
||||||
|
const dedication = props.general?.progression?.dedicationText ?? '무품관';
|
||||||
|
return `${penalty} · 계급 ${dedication}`;
|
||||||
|
});
|
||||||
|
const displayDefence = computed(() => props.defenceText ?? '-');
|
||||||
|
const specialText = computed(() => {
|
||||||
|
const traits = props.general?.traits;
|
||||||
|
return traits ? `${traits.specialDomestic || '-'} / ${traits.specialWar || '-'}` : '-';
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="general-card">
|
<div class="general-card" data-general-basic-card>
|
||||||
<div v-if="props.loading">
|
<div v-if="props.loading" class="general-loading">
|
||||||
<SkeletonLines :lines="5" />
|
<SkeletonLines :lines="5" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
|
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
|
||||||
<div v-else class="general-body">
|
<template v-else>
|
||||||
<div class="general-title">
|
<div class="general-basic-grid general-body">
|
||||||
{{ props.general.name }} · {{ props.general.officerLevelText }} · {{ props.general.age ?? '-' }}세 ·
|
<span
|
||||||
다음 턴
|
class="general-image general-icon"
|
||||||
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
|
role="img"
|
||||||
</div>
|
:aria-label="`${props.general.name} 초상`"
|
||||||
|
:style="{ backgroundImage: generalIconBackground }"
|
||||||
|
/>
|
||||||
|
<div class="general-title battle-general-name" :style="titleStyle">
|
||||||
|
{{ props.general.name }} 【 {{ props.general.officerLevelText }} |
|
||||||
|
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 】 다음 턴
|
||||||
|
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="stat-progress-grid">
|
|
||||||
<template v-for="stat of statRows" :key="stat.key">
|
<template v-for="stat of statRows" :key="stat.key">
|
||||||
<span class="cell-label">{{ stat.label }}</span>
|
<span class="cell-label">{{ stat.label }}</span>
|
||||||
<strong>{{ stat.value }}</strong>
|
<strong class="stat-value">
|
||||||
<div class="bar-cell" :data-stat-progress="stat.key">
|
<span>{{ stat.value }}</span>
|
||||||
<LegacyProgressBar
|
<span class="bar-cell" :data-stat-progress="stat.key">
|
||||||
:percent="stat.percent"
|
<LegacyProgressBar
|
||||||
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
|
:percent="stat.percent"
|
||||||
/>
|
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
|
||||||
</div>
|
/>
|
||||||
|
</span>
|
||||||
|
</strong>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="legacy-grid">
|
<span class="cell-label">명마</span><strong>{{ itemNames.horse ?? '-' }}</strong>
|
||||||
<span>자금</span><strong>{{ props.general.gold.toLocaleString() }}</strong> <span>군량</span
|
<span class="cell-label">무기</span><strong>{{ itemNames.weapon ?? '-' }}</strong>
|
||||||
><strong>{{ props.general.rice.toLocaleString() }}</strong> <span>병력</span
|
<span class="cell-label">서적</span><strong>{{ itemNames.book ?? '-' }}</strong>
|
||||||
><strong>{{ props.general.crew.toLocaleString() }}</strong> <span>훈련</span
|
|
||||||
><strong>{{ props.general.train }}</strong> <span>사기</span><strong>{{ props.general.atmos }}</strong>
|
|
||||||
<span>부상</span><strong>{{ props.general.injury }}</strong> <span>병종</span
|
|
||||||
><strong>{{ props.general.crewTypeName ?? '-' }}</strong> <span>성격</span
|
|
||||||
><strong>{{ props.general.traits?.personal ?? '-' }}</strong> <span>전투특기</span
|
|
||||||
><strong>{{ props.general.traits?.specialWar ?? '-' }}</strong> <span>내정특기</span
|
|
||||||
><strong>{{ props.general.traits?.specialDomestic ?? '-' }}</strong> <span>계급</span
|
|
||||||
><strong>{{ props.general.progression?.dedicationText ?? '무품관' }}</strong> <span>공헌</span
|
|
||||||
><strong>{{ props.general.dedication.toLocaleString() }}</strong>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="experience-row">
|
<span
|
||||||
<span class="cell-label">Lv</span>
|
class="general-image general-crew-type-icon"
|
||||||
<strong>{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
|
role="img"
|
||||||
<div class="bar-cell" data-experience-progress>
|
:aria-label="`${props.general.crewTypeName ?? '병종'} 이미지`"
|
||||||
|
:style="{ backgroundImage: crewTypeIconBackground }"
|
||||||
|
/>
|
||||||
|
<span class="cell-label">자금</span><strong>{{ props.general.gold.toLocaleString('ko-KR') }}</strong>
|
||||||
|
<span class="cell-label">군량</span><strong>{{ props.general.rice.toLocaleString('ko-KR') }}</strong>
|
||||||
|
<span class="cell-label">도구</span><strong>{{ itemNames.item ?? '-' }}</strong>
|
||||||
|
|
||||||
|
<span class="cell-label">병종</span><strong>{{ props.general.crewTypeName ?? '-' }}</strong>
|
||||||
|
<span class="cell-label">병사</span><strong>{{ props.general.crew.toLocaleString('ko-KR') }}</strong>
|
||||||
|
<span class="cell-label">성격</span><strong>{{ props.general.traits?.personal ?? '-' }}</strong>
|
||||||
|
|
||||||
|
<span class="cell-label">훈련</span><strong>{{ props.general.train }}</strong>
|
||||||
|
<span class="cell-label">사기</span><strong>{{ props.general.atmos }}</strong>
|
||||||
|
<span class="cell-label">특기</span><strong :title="specialText">{{ specialText }}</strong>
|
||||||
|
|
||||||
|
<span class="cell-label level-label">Lv</span>
|
||||||
|
<strong class="level-value">{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
|
||||||
|
<span class="experience-bar" data-experience-progress>
|
||||||
<LegacyProgressBar
|
<LegacyProgressBar
|
||||||
:percent="experiencePercent"
|
:percent="experiencePercent"
|
||||||
:label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`"
|
:label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`"
|
||||||
/>
|
/>
|
||||||
</div>
|
</span>
|
||||||
<span class="experience-total">명성 {{ props.general.experience.toLocaleString() }}</span>
|
<span class="cell-label age-label">연령</span>
|
||||||
|
<strong class="age-value" :style="{ color: ageColor }">{{ props.general.age ?? '-' }}세</strong>
|
||||||
|
|
||||||
|
<span class="cell-label defence-label">수비</span>
|
||||||
|
<strong class="defence-value">{{ displayDefence }}</strong>
|
||||||
|
<span class="cell-label kill-label">삭턴</span>
|
||||||
|
<strong class="kill-value">{{ props.killTurn === null ? '-' : `${props.killTurn} 턴` }}</strong>
|
||||||
|
<span class="cell-label execute-label">실행</span>
|
||||||
|
<strong class="execute-value">{{
|
||||||
|
props.remainingMinutes === null ? '-' : `${props.remainingMinutes}분 남음`
|
||||||
|
}}</strong>
|
||||||
|
|
||||||
|
<span class="cell-label troop-label">부대</span>
|
||||||
|
<strong class="troop-value">{{ displayTroop }}</strong>
|
||||||
|
<span class="cell-label penalty-label">벌점</span>
|
||||||
|
<strong class="penalty-value">{{ displayPenalty }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<slot name="details" />
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.general-title {
|
.general-card {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: 20px;
|
width: 100%;
|
||||||
min-height: 20px;
|
min-width: 0;
|
||||||
padding: 1px 6px;
|
|
||||||
border-bottom: 1px solid #777;
|
|
||||||
background: #173d27;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-progress-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(30px, 1fr) minmax(34px, 1fr) 45px);
|
|
||||||
grid-auto-rows: 21px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-progress-grid > *,
|
|
||||||
.legacy-grid > * {
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 21px;
|
|
||||||
min-height: 0;
|
|
||||||
border-right: 1px solid #666;
|
|
||||||
border-bottom: 1px solid #666;
|
|
||||||
padding: 2px 4px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background-color: #172a52;
|
||||||
|
background-image: var(--sammo-texture-blue);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.general-basic-grid {
|
||||||
|
display: grid;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: 64px repeat(3, minmax(30px, 2fr) minmax(60px, 5fr));
|
||||||
|
grid-template-rows: repeat(9, calc(64px / 3));
|
||||||
|
border-right: 1px solid #777;
|
||||||
|
border-bottom: 1px solid #777;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.general-basic-grid > * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
border-top: 1px solid #777;
|
||||||
|
border-left: 1px solid #777;
|
||||||
|
padding: 1px 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell-label,
|
.general-basic-grid > strong {
|
||||||
.legacy-grid > span {
|
font-weight: 500;
|
||||||
background: rgb(20 75 42 / 70%);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-progress-grid > strong,
|
.cell-label {
|
||||||
.legacy-grid > strong {
|
background-color: rgb(20 75 42 / 70%);
|
||||||
text-align: right;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-cell {
|
.general-image {
|
||||||
|
display: block;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
padding: 0;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.general-icon {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.general-title {
|
||||||
|
grid-column: 2 / 8;
|
||||||
|
grid-row: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(22px, auto) minmax(26px, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-cell,
|
||||||
|
.experience-bar {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
padding: 0 1px;
|
padding: 0 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legacy-grid {
|
.general-crew-type-icon {
|
||||||
display: grid;
|
grid-column: 1;
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-row: 4 / 7;
|
||||||
grid-auto-rows: 21px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.experience-row {
|
.level-label {
|
||||||
display: grid;
|
grid-column: 1;
|
||||||
box-sizing: border-box;
|
grid-row: 7;
|
||||||
grid-template-columns: 32px 38px minmax(120px, 1fr) 112px;
|
|
||||||
height: 20px;
|
|
||||||
min-height: 20px;
|
|
||||||
border-bottom: 1px solid #666;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.experience-row > * {
|
.level-value {
|
||||||
display: grid;
|
grid-column: 2;
|
||||||
align-content: center;
|
grid-row: 7;
|
||||||
box-sizing: border-box;
|
}
|
||||||
border-right: 1px solid #666;
|
|
||||||
padding: 1px 4px;
|
.experience-bar {
|
||||||
text-align: center;
|
grid-column: 3 / 6;
|
||||||
|
grid-row: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.age-label {
|
||||||
|
grid-column: 6;
|
||||||
|
grid-row: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.age-value {
|
||||||
|
grid-column: 7;
|
||||||
|
grid-row: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defence-label {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defence-value {
|
||||||
|
grid-column: 2 / 4;
|
||||||
|
grid-row: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-label {
|
||||||
|
grid-column: 4;
|
||||||
|
grid-row: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-value {
|
||||||
|
grid-column: 5;
|
||||||
|
grid-row: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execute-label {
|
||||||
|
grid-column: 6;
|
||||||
|
grid-row: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execute-value {
|
||||||
|
grid-column: 7;
|
||||||
|
grid-row: 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troop-label {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troop-value {
|
||||||
|
grid-column: 2 / 4;
|
||||||
|
grid-row: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-label {
|
||||||
|
grid-column: 4;
|
||||||
|
grid-row: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-value {
|
||||||
|
grid-column: 5 / 8;
|
||||||
|
grid-row: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.general-loading,
|
||||||
|
.empty {
|
||||||
|
min-height: 192px;
|
||||||
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import { computed } from 'vue';
|
||||||
|
import { resolveTournamentStageName } from '../../utils/tournamentStatus';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tournamentStage: number;
|
||||||
status: {
|
status: {
|
||||||
onlineUserCount: number;
|
onlineUserCount: number;
|
||||||
onlineNations: string;
|
onlineNations: string;
|
||||||
@@ -13,15 +17,24 @@ defineProps<{
|
|||||||
} | null;
|
} | null;
|
||||||
} | null;
|
} | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="front-status" aria-label="접속 현황과 국가 방침">
|
<section class="front-status" aria-label="접속 현황과 국가 방침">
|
||||||
<div class="status-row vote-status">
|
<div class="activity-status" aria-label="설문과 토너먼트 진행 현황">
|
||||||
<RouterLink v-if="status?.latestVote" to="/survey">
|
<div class="status-row tournament-status">
|
||||||
<span class="vote-label">설문 진행 중: </span>{{ status.latestVote.title }}
|
<RouterLink to="/tournament">
|
||||||
</RouterLink>
|
<span class="tournament-label">토너먼트: </span>{{ tournamentStatus }}
|
||||||
<span v-else class="vote-empty">진행중인 설문 없음</span>
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="status-row vote-status">
|
||||||
|
<RouterLink v-if="status?.latestVote" to="/survey">
|
||||||
|
<span class="vote-label">설문: </span>{{ status.latestVote.title }}
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else class="vote-empty">설문: 진행 중인 설문 없음</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-row online-nations">접속중인 국가: {{ status?.onlineNations ?? '' }}</div>
|
<div class="status-row online-nations">접속중인 국가: {{ status?.onlineNations ?? '' }}</div>
|
||||||
<div class="status-row online-users">【 접속자 】 {{ status?.onlineGenerals ?? '' }}</div>
|
<div class="status-row online-users">【 접속자 】 {{ status?.onlineGenerals ?? '' }}</div>
|
||||||
@@ -71,19 +84,28 @@ defineProps<{
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vote-status {
|
.activity-status {
|
||||||
width: 33.333333%;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
width: 66.666667%;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-status .status-row {
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vote-status a {
|
.activity-status a {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-decoration: gray underline;
|
text-decoration: gray underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tournament-label {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
.vote-label {
|
.vote-label {
|
||||||
color: cyan;
|
color: cyan;
|
||||||
}
|
}
|
||||||
@@ -93,8 +115,8 @@ defineProps<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 991px) {
|
@media (max-width: 991px) {
|
||||||
.vote-status {
|
.activity-status {
|
||||||
width: 50%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
interface MapCityView {
|
interface MapCityView {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -20,6 +21,7 @@ const props = defineProps<{
|
|||||||
city: MapCityView;
|
city: MapCityView;
|
||||||
showName: boolean;
|
showName: boolean;
|
||||||
mapScale: number;
|
mapScale: number;
|
||||||
|
selectOnly?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -31,12 +33,15 @@ const emit = defineEmits<{
|
|||||||
const size = computed(() => (6 + props.city.level * 2) * props.mapScale);
|
const size = computed(() => (6 + props.city.level * 2) * props.mapScale);
|
||||||
const stateSize = computed(() => 8 * props.mapScale);
|
const stateSize = computed(() => 8 * props.mapScale);
|
||||||
const stateOffset = computed(() => -6 * props.mapScale);
|
const stateOffset = computed(() => -6 * props.mapScale);
|
||||||
|
const selectCity = () => emit('select', props.city.id);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterLink
|
<component
|
||||||
|
:is="props.selectOnly ? 'button' : RouterLink"
|
||||||
class="map-city"
|
class="map-city"
|
||||||
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
|
:type="props.selectOnly ? 'button' : undefined"
|
||||||
|
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }"
|
||||||
:class="[
|
:class="[
|
||||||
`state-${props.city.stateClass}`,
|
`state-${props.city.stateClass}`,
|
||||||
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
|
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
|
||||||
@@ -44,7 +49,7 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
|||||||
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
|
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
|
||||||
@mouseenter="emit('hover', props.city.id)"
|
@mouseenter="emit('hover', props.city.id)"
|
||||||
@mouseleave="emit('leave')"
|
@mouseleave="emit('leave')"
|
||||||
@click.stop="emit('select', props.city.id)"
|
@click.stop="selectCity"
|
||||||
>
|
>
|
||||||
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
|
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
|
||||||
<span v-if="props.city.isCapital" class="capital" />
|
<span v-if="props.city.isCapital" class="capital" />
|
||||||
@@ -61,7 +66,7 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
|||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
|
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
|
||||||
</RouterLink>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -76,6 +81,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
|||||||
color: rgba(232, 221, 196, 0.8);
|
color: rgba(232, 221, 196, 0.8);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.city-dot {
|
.city-dot {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
import { buildAssetUrl, normalizeColorToken } from '../../utils/mapAssets';
|
import { buildAssetUrl, normalizeColorToken } from '../../utils/mapAssets';
|
||||||
|
|
||||||
interface MapCityView {
|
interface MapCityView {
|
||||||
@@ -46,6 +47,7 @@ const props = defineProps<{
|
|||||||
imageBaseUrl: string;
|
imageBaseUrl: string;
|
||||||
themeName: string;
|
themeName: string;
|
||||||
mapScale: number;
|
mapScale: number;
|
||||||
|
selectOnly?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -141,6 +143,8 @@ const capitalIconStyle = computed(() => ({
|
|||||||
height: `${10 * props.mapScale}px`,
|
height: `${10 * props.mapScale}px`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const selectCity = () => emit('select', props.city.id);
|
||||||
|
|
||||||
const cityStateStyle = computed(() => ({
|
const cityStateStyle = computed(() => ({
|
||||||
width: `${12 * props.mapScale}px`,
|
width: `${12 * props.mapScale}px`,
|
||||||
height: `${12 * props.mapScale}px`,
|
height: `${12 * props.mapScale}px`,
|
||||||
@@ -149,14 +153,16 @@ const cityStateStyle = computed(() => ({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterLink
|
<component
|
||||||
|
:is="props.selectOnly ? 'button' : RouterLink"
|
||||||
class="city-base"
|
class="city-base"
|
||||||
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
|
:type="props.selectOnly ? 'button' : undefined"
|
||||||
|
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }"
|
||||||
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
|
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
|
||||||
:style="cityBaseStyle"
|
:style="cityBaseStyle"
|
||||||
@mouseenter="emit('hover', props.city.id)"
|
@mouseenter="emit('hover', props.city.id)"
|
||||||
@mouseleave="emit('leave')"
|
@mouseleave="emit('leave')"
|
||||||
@click.stop="emit('select', props.city.id)"
|
@click.stop="selectCity"
|
||||||
>
|
>
|
||||||
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
|
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
|
||||||
<div class="city-img" :style="cityIconStyle">
|
<div class="city-img" :style="cityIconStyle">
|
||||||
@@ -173,7 +179,7 @@ const cityStateStyle = computed(() => ({
|
|||||||
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
|
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
|
||||||
<img :src="stateIcon" />
|
<img :src="stateIcon" />
|
||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -184,6 +190,9 @@ const cityStateStyle = computed(() => ({
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: auto;
|
cursor: auto;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.city-bg {
|
.city-bg {
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ const props = defineProps<{
|
|||||||
mapData: MapSummary | null;
|
mapData: MapSummary | null;
|
||||||
mapLayout: MapLayout | null;
|
mapLayout: MapLayout | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
selectedCityId?: number | null;
|
||||||
|
detailMode?: boolean;
|
||||||
|
fitContainer?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'select-city', cityId: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const BASE_MAP_WIDTH = 700;
|
const BASE_MAP_WIDTH = 700;
|
||||||
@@ -75,7 +82,12 @@ const SMALL_MAP_SCALE = 5 / 7;
|
|||||||
|
|
||||||
const isWide = useMediaQuery('(min-width: 1024px)');
|
const isWide = useMediaQuery('(min-width: 1024px)');
|
||||||
const mapStore = useMapViewerStore();
|
const mapStore = useMapViewerStore();
|
||||||
const { showCityName, detailMode, hoveredCityId, selectedCityId } = storeToRefs(mapStore);
|
const {
|
||||||
|
showCityName,
|
||||||
|
detailMode: storeDetailMode,
|
||||||
|
hoveredCityId,
|
||||||
|
selectedCityId: storeSelectedCityId,
|
||||||
|
} = storeToRefs(mapStore);
|
||||||
|
|
||||||
const mapArea = ref<HTMLElement | null>(null);
|
const mapArea = ref<HTMLElement | null>(null);
|
||||||
const mapBody = ref<HTMLElement | null>(null);
|
const mapBody = ref<HTMLElement | null>(null);
|
||||||
@@ -140,15 +152,20 @@ const dynamicCityById = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mapScale = computed(() => {
|
const mapScale = computed(() => {
|
||||||
if (isWide.value) {
|
if (isWide.value && !props.fitContainer) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if (mapBodyWidth.value <= 0) {
|
if (mapBodyWidth.value <= 0) {
|
||||||
return SMALL_MAP_SCALE;
|
return SMALL_MAP_SCALE;
|
||||||
}
|
}
|
||||||
return Math.min(SMALL_MAP_SCALE, mapBodyWidth.value / BASE_MAP_WIDTH);
|
return Math.min(props.fitContainer ? 1 : SMALL_MAP_SCALE, mapBodyWidth.value / BASE_MAP_WIDTH);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const effectiveDetailMode = computed(() => props.detailMode ?? storeDetailMode.value);
|
||||||
|
const effectiveSelectedCityId = computed(() =>
|
||||||
|
props.selectedCityId === undefined ? storeSelectedCityId.value : props.selectedCityId
|
||||||
|
);
|
||||||
|
|
||||||
const mapWidth = computed(() => `${BASE_MAP_WIDTH * mapScale.value}px`);
|
const mapWidth = computed(() => `${BASE_MAP_WIDTH * mapScale.value}px`);
|
||||||
|
|
||||||
const mapHeight = computed(() => `${BASE_MAP_HEIGHT * mapScale.value}px`);
|
const mapHeight = computed(() => `${BASE_MAP_HEIGHT * mapScale.value}px`);
|
||||||
@@ -185,7 +202,7 @@ const cityViews = computed<CityView[]>(() => {
|
|||||||
y,
|
y,
|
||||||
isCapital: nation?.capitalCityId === layoutCity.id,
|
isCapital: nation?.capitalCityId === layoutCity.id,
|
||||||
isMyCity: props.mapData?.myCity === layoutCity.id,
|
isMyCity: props.mapData?.myCity === layoutCity.id,
|
||||||
selected: selectedCityId.value === layoutCity.id,
|
selected: effectiveSelectedCityId.value === layoutCity.id,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -258,7 +275,7 @@ const titleTooltipLines = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const titleBandStyle = computed(() =>
|
const titleBandStyle = computed(() =>
|
||||||
detailMode.value
|
effectiveDetailMode.value
|
||||||
? {
|
? {
|
||||||
backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`,
|
backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`,
|
||||||
}
|
}
|
||||||
@@ -266,7 +283,7 @@ const titleBandStyle = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const titleTextStyle = computed(() =>
|
const titleTextStyle = computed(() =>
|
||||||
detailMode.value
|
effectiveDetailMode.value
|
||||||
? {
|
? {
|
||||||
color: titleColor.value,
|
color: titleColor.value,
|
||||||
backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`,
|
backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`,
|
||||||
@@ -327,7 +344,7 @@ const mapRoadStyle = computed(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const detailProps = computed(() =>
|
const detailProps = computed(() =>
|
||||||
detailMode.value
|
effectiveDetailMode.value
|
||||||
? {
|
? {
|
||||||
imageBaseUrl: assetBaseUrl.value,
|
imageBaseUrl: assetBaseUrl.value,
|
||||||
themeName: mapTheme.value,
|
themeName: mapTheme.value,
|
||||||
@@ -365,7 +382,10 @@ const setHoveredCity = (cityId: number | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectCity = (cityId: number) => {
|
const selectCity = (cityId: number) => {
|
||||||
mapStore.setSelectedCity(cityId);
|
emit('select-city', cityId);
|
||||||
|
if (props.selectedCityId === undefined) {
|
||||||
|
mapStore.setSelectedCity(cityId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -394,12 +414,13 @@ const selectCity = (cityId: number) => {
|
|||||||
<div class="map-layer map-bglayer2" />
|
<div class="map-layer map-bglayer2" />
|
||||||
<div v-if="mapRoadImage" class="map-layer map-bgroad" :style="mapRoadStyle" />
|
<div v-if="mapRoadImage" class="map-layer map-bgroad" :style="mapRoadStyle" />
|
||||||
<component
|
<component
|
||||||
:is="detailMode ? MapCityDetail : MapCityBasic"
|
:is="effectiveDetailMode ? MapCityDetail : MapCityBasic"
|
||||||
v-for="city in cityViews"
|
v-for="city in cityViews"
|
||||||
:key="city.id"
|
:key="city.id"
|
||||||
:city="city"
|
:city="city"
|
||||||
:map-scale="mapScale"
|
:map-scale="mapScale"
|
||||||
:show-name="showCityName"
|
:show-name="showCityName"
|
||||||
|
:select-only="props.selectedCityId !== undefined"
|
||||||
v-bind="detailProps"
|
v-bind="detailProps"
|
||||||
@hover="setHoveredCity"
|
@hover="setHoveredCity"
|
||||||
@leave="setHoveredCity(null)"
|
@leave="setHoveredCity(null)"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import GeneralIdentity from '../ui/GeneralIdentity.vue';
|
||||||
import {
|
import {
|
||||||
buildTournamentBracket,
|
buildTournamentBracket,
|
||||||
type TournamentBracketMatch,
|
type TournamentBracketMatch,
|
||||||
@@ -89,7 +90,12 @@ const odds = (id: number | null) => {
|
|||||||
:class="{ advanced: bracket.champion.advanced }"
|
:class="{ advanced: bracket.champion.advanced }"
|
||||||
:data-general-id="bracket.champion.id ?? undefined"
|
:data-general-id="bracket.champion.id ?? undefined"
|
||||||
>
|
>
|
||||||
{{ bracket.champion.name }}
|
<GeneralIdentity
|
||||||
|
:name="bracket.champion.name"
|
||||||
|
:picture="bracket.champion.picture"
|
||||||
|
:image-server="bracket.champion.imageServer"
|
||||||
|
:icon-size="24"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,7 +116,12 @@ const odds = (id: number | null) => {
|
|||||||
:class="{ advanced: slot.advanced }"
|
:class="{ advanced: slot.advanced }"
|
||||||
:data-general-id="slot.id ?? undefined"
|
:data-general-id="slot.id ?? undefined"
|
||||||
>
|
>
|
||||||
{{ slot.name }}
|
<GeneralIdentity
|
||||||
|
:name="slot.name"
|
||||||
|
:picture="slot.picture"
|
||||||
|
:image-server="slot.imageServer"
|
||||||
|
:icon-size="22"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="connector-row" :style="{ '--connector-count': round.slots.length }">
|
<div class="connector-row" :style="{ '--connector-count': round.slots.length }">
|
||||||
@@ -140,7 +151,12 @@ const odds = (id: number | null) => {
|
|||||||
:class="{ advanced: slot.advanced }"
|
:class="{ advanced: slot.advanced }"
|
||||||
:data-general-id="slot.id ?? undefined"
|
:data-general-id="slot.id ?? undefined"
|
||||||
>
|
>
|
||||||
{{ slot.name }}
|
<GeneralIdentity
|
||||||
|
:name="slot.name"
|
||||||
|
:picture="slot.picture"
|
||||||
|
:image-server="slot.imageServer"
|
||||||
|
:icon-size="20"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bracket-round bracket-odds" :style="roundStyle(bracket.top16)">
|
<div class="bracket-round bracket-odds" :style="roundStyle(bracket.top16)">
|
||||||
@@ -183,9 +199,17 @@ const odds = (id: number | null) => {
|
|||||||
:key="`mobile-${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
|
:key="`mobile-${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
|
||||||
class="mobile-bracket-name"
|
class="mobile-bracket-name"
|
||||||
:class="{ advanced: slot.advanced }"
|
:class="{ advanced: slot.advanced }"
|
||||||
:style="{ left: `${mobileX[columnIndex]}px`, top: `${mobileY(columnIndex, slotIndex)}px` }"
|
:style="{
|
||||||
|
left: `${(mobileX[columnIndex]! / 390) * 100}%`,
|
||||||
|
top: `${mobileY(columnIndex, slotIndex)}px`,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
{{ slot.name }}
|
<GeneralIdentity
|
||||||
|
:name="slot.name"
|
||||||
|
:picture="slot.picture"
|
||||||
|
:image-server="slot.imageServer"
|
||||||
|
:icon-size="18"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,21 +234,23 @@ const odds = (id: number | null) => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.bracket-canvas {
|
.bracket-canvas {
|
||||||
width: 2000px;
|
width: 100%;
|
||||||
min-width: 2000px;
|
min-width: 1000px;
|
||||||
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
.mobile-bracket {
|
.mobile-bracket {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: none;
|
display: none;
|
||||||
width: 390px;
|
width: 100%;
|
||||||
|
max-width: 390px;
|
||||||
height: 544px;
|
height: 544px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
.mobile-bracket svg {
|
.mobile-bracket svg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 390px;
|
width: 100%;
|
||||||
height: 544px;
|
height: 544px;
|
||||||
}
|
}
|
||||||
.mobile-connector {
|
.mobile-connector {
|
||||||
@@ -239,14 +265,16 @@ const odds = (id: number | null) => {
|
|||||||
.mobile-bracket-name {
|
.mobile-bracket-name {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 64px;
|
width: clamp(58px, 18vw, 72px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
background: rgb(58 33 24 / 92%);
|
background: rgb(58 33 24 / 92%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
min-height: 26px;
|
||||||
line-height: 22px;
|
padding: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 20px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -265,7 +293,7 @@ const odds = (id: number | null) => {
|
|||||||
}
|
}
|
||||||
.bracket-name {
|
.bracket-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0 3px;
|
padding: 2px 3px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -321,8 +349,8 @@ const odds = (id: number | null) => {
|
|||||||
}
|
}
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.tournament-bracket {
|
.tournament-bracket {
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100%;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
.bracket-canvas {
|
.bracket-canvas {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon, type GeneralIconSource } from '../../utils/generalIcon';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
name: string;
|
||||||
|
picture?: GeneralIconSource['picture'];
|
||||||
|
imageServer?: GeneralIconSource['imageServer'];
|
||||||
|
iconSize?: number;
|
||||||
|
hideIcon?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
iconSize: 28,
|
||||||
|
hideIcon: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconUrl = computed(() =>
|
||||||
|
resolveGeneralIconUrl({
|
||||||
|
picture: props.picture,
|
||||||
|
imageServer: props.imageServer,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const identityStyle = computed(() => ({ '--general-identity-icon-size': `${props.iconSize}px` }));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="general-identity" :style="identityStyle">
|
||||||
|
<img
|
||||||
|
v-if="!hideIcon && name !== '-'"
|
||||||
|
class="general-identity-icon"
|
||||||
|
:src="iconUrl"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
@error="useDefaultGeneralIcon"
|
||||||
|
/>
|
||||||
|
<span class="general-identity-name">{{ name }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.general-identity {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.general-identity-icon {
|
||||||
|
width: var(--general-identity-icon-size);
|
||||||
|
height: var(--general-identity-icon-size);
|
||||||
|
flex: 0 0 var(--general-identity-icon-size);
|
||||||
|
border: 1px solid rgb(255 255 255 / 28%);
|
||||||
|
background: #111;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.general-identity-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,7 +15,9 @@ type GeneralProgress = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = defineProps<{ general: GeneralProgress }>();
|
const props = withDefaults(defineProps<{ general: GeneralProgress; showPrimary?: boolean }>(), {
|
||||||
|
showPrimary: true,
|
||||||
|
});
|
||||||
|
|
||||||
const statRows = computed(() =>
|
const statRows = computed(() =>
|
||||||
[
|
[
|
||||||
@@ -50,7 +52,7 @@ const experiencePercent = computed(() =>
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="legacy-general-progress">
|
<div class="legacy-general-progress">
|
||||||
<div class="stat-grid">
|
<div v-if="props.showPrimary" class="stat-grid">
|
||||||
<template v-for="stat of statRows" :key="stat.key">
|
<template v-for="stat of statRows" :key="stat.key">
|
||||||
<span class="cell-label">{{ stat.label }}</span>
|
<span class="cell-label">{{ stat.label }}</span>
|
||||||
<strong>{{ stat.value }}</strong>
|
<strong>{{ stat.value }}</strong>
|
||||||
@@ -60,7 +62,7 @@ const experiencePercent = computed(() =>
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="experience-row">
|
<div v-if="props.showPrimary" class="experience-row">
|
||||||
<span class="cell-label">Lv</span>
|
<span class="cell-label">Lv</span>
|
||||||
<strong>{{ props.general.progression.experienceLevel }}</strong>
|
<strong>{{ props.general.progression.experienceLevel }}</strong>
|
||||||
<LegacyProgressBar
|
<LegacyProgressBar
|
||||||
|
|||||||
@@ -1,24 +1,6 @@
|
|||||||
const KOREA_TIME_OFFSET_MS = 9 * 60 * 60 * 1000;
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
|
|
||||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
export const formatSeoulDateTime = (value: string | Date): string => formatServerDateTime(value);
|
||||||
|
|
||||||
export const formatSeoulDateTime = (value: string | Date): string => {
|
export const formatSeoulHourMinute = (value: string | Date): string =>
|
||||||
if (
|
formatServerDateTime(value, { format: 'hourMinute' });
|
||||||
typeof value === 'string' &&
|
|
||||||
!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value.trim())
|
|
||||||
) {
|
|
||||||
return value.trim().replace('T', ' ').slice(0, 19);
|
|
||||||
}
|
|
||||||
const date = value instanceof Date ? value : new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return typeof value === 'string' ? value.slice(0, 19) : '';
|
|
||||||
}
|
|
||||||
const koreaTime = new Date(date.getTime() + KOREA_TIME_OFFSET_MS);
|
|
||||||
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
|
|
||||||
koreaTime.getUTCDate()
|
|
||||||
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(
|
|
||||||
koreaTime.getUTCSeconds()
|
|
||||||
)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatSeoulHourMinute = (value: string | Date): string => formatSeoulDateTime(value).slice(11, 16);
|
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
export type NationGeneralColumnId =
|
||||||
|
| 'icon'
|
||||||
|
| 'name'
|
||||||
|
| 'officerLevel'
|
||||||
|
| 'expDedLv_1'
|
||||||
|
| 'dedlevel'
|
||||||
|
| 'explevel'
|
||||||
|
| 'stat_1'
|
||||||
|
| 'leadership'
|
||||||
|
| 'strength'
|
||||||
|
| 'intel'
|
||||||
|
| 'troop'
|
||||||
|
| 'goldRice_1'
|
||||||
|
| 'gold'
|
||||||
|
| 'rice'
|
||||||
|
| 'city'
|
||||||
|
| 'crew'
|
||||||
|
| 'specials_1'
|
||||||
|
| 'personal'
|
||||||
|
| 'specialDomestic'
|
||||||
|
| 'specialWar'
|
||||||
|
| 'years_1'
|
||||||
|
| 'belong'
|
||||||
|
| 'killturnAndRefresh_1'
|
||||||
|
| 'refreshScoreTotal';
|
||||||
|
|
||||||
|
export type NationGeneralGroupId = 'expDedLv' | 'stat' | 'goldRice' | 'specials' | 'years' | 'killturnAndRefresh';
|
||||||
|
export type SortDirection = 'asc' | 'desc';
|
||||||
|
export type NationGeneralViewMode = 'normal' | 'war';
|
||||||
|
|
||||||
|
export type NationGeneralColumnState = {
|
||||||
|
colId: NationGeneralColumnId;
|
||||||
|
width: number;
|
||||||
|
hide: boolean;
|
||||||
|
sort: SortDirection | null;
|
||||||
|
sortIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NationGeneralGroupState = {
|
||||||
|
groupId: NationGeneralGroupId;
|
||||||
|
open: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NationGeneralDisplaySetting = {
|
||||||
|
column: NationGeneralColumnState[];
|
||||||
|
columnGroup: NationGeneralGroupState[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NationGeneralSettingKey = [true, NationGeneralViewMode] | [false, string];
|
||||||
|
|
||||||
|
export const DISPLAY_SETTINGS_KEY = 'GeneralListDisplaySetting';
|
||||||
|
export const DISPLAY_SETTINGS_VERSION = 1;
|
||||||
|
export const lastUsedSettingsKey = (role: string): string => `LastUsedSettingsKey_${role}`;
|
||||||
|
|
||||||
|
const baseColumns = (): NationGeneralColumnState[] => [
|
||||||
|
{ colId: 'icon', width: 80, hide: false, sort: null },
|
||||||
|
{ colId: 'name', width: 126, hide: false, sort: null },
|
||||||
|
{ colId: 'officerLevel', width: 70, hide: false, sort: null },
|
||||||
|
{ colId: 'expDedLv_1', width: 60, hide: false, sort: null },
|
||||||
|
{ colId: 'dedlevel', width: 70, hide: false, sort: null },
|
||||||
|
{ colId: 'explevel', width: 60, hide: false, sort: null },
|
||||||
|
{ colId: 'stat_1', width: 88, hide: false, sort: null },
|
||||||
|
{ colId: 'leadership', width: 60, hide: false, sort: null },
|
||||||
|
{ colId: 'strength', width: 60, hide: false, sort: null },
|
||||||
|
{ colId: 'intel', width: 60, hide: false, sort: null },
|
||||||
|
{ colId: 'troop', width: 90, hide: true, sort: null },
|
||||||
|
{ colId: 'goldRice_1', width: 80, hide: false, sort: null },
|
||||||
|
{ colId: 'gold', width: 70, hide: false, sort: null },
|
||||||
|
{ colId: 'rice', width: 70, hide: false, sort: null },
|
||||||
|
{ colId: 'city', width: 60, hide: true, sort: null },
|
||||||
|
{ colId: 'crew', width: 70, hide: true, sort: null },
|
||||||
|
{ colId: 'specials_1', width: 80, hide: false, sort: null },
|
||||||
|
{ colId: 'personal', width: 60, hide: false, sort: null },
|
||||||
|
{ colId: 'specialDomestic', width: 60, hide: false, sort: null },
|
||||||
|
{ colId: 'specialWar', width: 60, hide: false, sort: null },
|
||||||
|
{ colId: 'years_1', width: 60, hide: false, sort: null },
|
||||||
|
{ colId: 'belong', width: 60, hide: false, sort: null },
|
||||||
|
{ colId: 'killturnAndRefresh_1', width: 70, hide: false, sort: null },
|
||||||
|
{ colId: 'refreshScoreTotal', width: 70, hide: false, sort: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const groupState = (overrides: Partial<Record<NationGeneralGroupId, boolean>>): NationGeneralGroupState[] =>
|
||||||
|
(['expDedLv', 'stat', 'goldRice', 'specials', 'years', 'killturnAndRefresh'] as const).map((groupId) => ({
|
||||||
|
groupId,
|
||||||
|
open: overrides[groupId] ?? false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const withColumnOverrides = (
|
||||||
|
columns: NationGeneralColumnState[],
|
||||||
|
overrides: Partial<Record<NationGeneralColumnId, Partial<NationGeneralColumnState>>>
|
||||||
|
): NationGeneralColumnState[] =>
|
||||||
|
columns.map((column) => ({
|
||||||
|
...column,
|
||||||
|
...overrides[column.colId],
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const defaultNationGeneralDisplaySettings: Record<NationGeneralViewMode, NationGeneralDisplaySetting> = {
|
||||||
|
normal: {
|
||||||
|
column: withColumnOverrides(baseColumns(), {
|
||||||
|
troop: { hide: true },
|
||||||
|
city: { hide: true },
|
||||||
|
crew: { hide: true },
|
||||||
|
refreshScoreTotal: { sort: 'desc', sortIndex: 0 },
|
||||||
|
}),
|
||||||
|
columnGroup: groupState({
|
||||||
|
expDedLv: true,
|
||||||
|
stat: true,
|
||||||
|
goldRice: true,
|
||||||
|
specials: false,
|
||||||
|
years: false,
|
||||||
|
killturnAndRefresh: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
war: {
|
||||||
|
column: withColumnOverrides(baseColumns(), {
|
||||||
|
icon: { hide: true },
|
||||||
|
officerLevel: { hide: true },
|
||||||
|
expDedLv_1: { hide: true },
|
||||||
|
dedlevel: { hide: true },
|
||||||
|
explevel: { hide: true },
|
||||||
|
troop: { hide: false },
|
||||||
|
city: { hide: false },
|
||||||
|
crew: { hide: false },
|
||||||
|
specials_1: { hide: true },
|
||||||
|
personal: { hide: true },
|
||||||
|
specialDomestic: { hide: true },
|
||||||
|
specialWar: { hide: true },
|
||||||
|
years_1: { hide: true },
|
||||||
|
belong: { hide: true },
|
||||||
|
killturnAndRefresh_1: { hide: true },
|
||||||
|
refreshScoreTotal: { hide: true },
|
||||||
|
}),
|
||||||
|
columnGroup: groupState({
|
||||||
|
expDedLv: false,
|
||||||
|
stat: false,
|
||||||
|
goldRice: true,
|
||||||
|
specials: false,
|
||||||
|
years: false,
|
||||||
|
killturnAndRefresh: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cloneNationGeneralDisplaySetting = (
|
||||||
|
setting: NationGeneralDisplaySetting
|
||||||
|
): NationGeneralDisplaySetting => ({
|
||||||
|
column: setting.column.map((column) => ({ ...column })),
|
||||||
|
columnGroup: setting.columnGroup.map((group) => ({ ...group })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const validColumnIds = new Set<NationGeneralColumnId>(baseColumns().map((column) => column.colId));
|
||||||
|
const validGroupIds = new Set<NationGeneralGroupId>(groupState({}).map((group) => group.groupId));
|
||||||
|
|
||||||
|
const isSortDirection = (value: unknown): value is SortDirection => value === 'asc' || value === 'desc';
|
||||||
|
|
||||||
|
export const normalizeNationGeneralDisplaySetting = (raw: unknown): NationGeneralDisplaySetting | null => {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const candidate = raw as { column?: unknown; columnGroup?: unknown };
|
||||||
|
if (!Array.isArray(candidate.column) || !Array.isArray(candidate.columnGroup)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = cloneNationGeneralDisplaySetting(defaultNationGeneralDisplaySettings.normal);
|
||||||
|
const rawColumns = new Map<string, Record<string, unknown>>();
|
||||||
|
for (const value of candidate.column) {
|
||||||
|
if (!value || typeof value !== 'object') continue;
|
||||||
|
const column = value as Record<string, unknown>;
|
||||||
|
if (typeof column.colId === 'string' && validColumnIds.has(column.colId as NationGeneralColumnId)) {
|
||||||
|
rawColumns.set(column.colId, column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallback.column = fallback.column.map((column) => {
|
||||||
|
const saved = rawColumns.get(column.colId);
|
||||||
|
if (!saved) return column;
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
width: typeof saved.width === 'number' && saved.width > 0 ? saved.width : column.width,
|
||||||
|
hide: typeof saved.hide === 'boolean' ? saved.hide : column.hide,
|
||||||
|
sort: isSortDirection(saved.sort) ? saved.sort : null,
|
||||||
|
...(typeof saved.sortIndex === 'number' && saved.sortIndex >= 0
|
||||||
|
? { sortIndex: Math.trunc(saved.sortIndex) }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawGroups = new Map<string, boolean>();
|
||||||
|
for (const value of candidate.columnGroup) {
|
||||||
|
if (!value || typeof value !== 'object') continue;
|
||||||
|
const group = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof group.groupId === 'string' &&
|
||||||
|
validGroupIds.has(group.groupId as NationGeneralGroupId) &&
|
||||||
|
typeof group.open === 'boolean'
|
||||||
|
) {
|
||||||
|
rawGroups.set(group.groupId, group.open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallback.columnGroup = fallback.columnGroup.map((group) => ({
|
||||||
|
...group,
|
||||||
|
open: rawGroups.get(group.groupId) ?? group.open,
|
||||||
|
}));
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseStoredDisplaySettings = (raw: string | null): Map<string, NationGeneralDisplaySetting> => {
|
||||||
|
if (!raw) return new Map();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { version?: unknown; settings?: unknown };
|
||||||
|
if (parsed.version !== DISPLAY_SETTINGS_VERSION || !Array.isArray(parsed.settings)) return new Map();
|
||||||
|
const result = new Map<string, NationGeneralDisplaySetting>();
|
||||||
|
for (const entry of parsed.settings) {
|
||||||
|
if (!Array.isArray(entry) || typeof entry[0] !== 'string') continue;
|
||||||
|
const setting = normalizeNationGeneralDisplaySetting(entry[1]);
|
||||||
|
if (setting) result.set(entry[0], setting);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeDisplaySettings = (settings: Map<string, NationGeneralDisplaySetting>): string =>
|
||||||
|
JSON.stringify({
|
||||||
|
version: DISPLAY_SETTINGS_VERSION,
|
||||||
|
settings: [...settings.entries()],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const parseStoredSettingKey = (raw: string | null): NationGeneralSettingKey | null => {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (
|
||||||
|
!Array.isArray(parsed) ||
|
||||||
|
parsed.length !== 2 ||
|
||||||
|
typeof parsed[0] !== 'boolean' ||
|
||||||
|
typeof parsed[1] !== 'string'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (parsed[0]) return parsed[1] === 'normal' || parsed[1] === 'war' ? [true, parsed[1]] : null;
|
||||||
|
return [false, parsed[1]];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialConsonants = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
|
||||||
|
|
||||||
|
const hangulInitials = (value: string): string =>
|
||||||
|
[...value]
|
||||||
|
.map((character) => {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
if (code < 0xac00 || code > 0xd7a3) return character;
|
||||||
|
return initialConsonants[Math.floor((code - 0xac00) / 588)] ?? character;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const normalizeSearchText = (value: string): string => value.toLocaleLowerCase('ko-KR').replace(/\s+/g, '');
|
||||||
|
|
||||||
|
export const matchesKoreanSearch = (value: string, query: string): boolean => {
|
||||||
|
const normalizedQuery = normalizeSearchText(query);
|
||||||
|
if (!normalizedQuery) return true;
|
||||||
|
const normalizedValue = normalizeSearchText(value);
|
||||||
|
return (
|
||||||
|
normalizedValue.includes(normalizedQuery) ||
|
||||||
|
normalizeSearchText(hangulInitials(value)).includes(normalizedQuery)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const matchesNumberSearch = (value: number | null, query: string): boolean => {
|
||||||
|
const normalized = query.trim();
|
||||||
|
if (!normalized) return true;
|
||||||
|
if (value === null || !Number.isFinite(value)) return false;
|
||||||
|
const match = /^(<=|>=|<|>|=)?\s*(-?\d+(?:\.\d+)?)$/.exec(normalized);
|
||||||
|
if (!match) return false;
|
||||||
|
const expected = Number(match[2]);
|
||||||
|
switch (match[1] ?? '=') {
|
||||||
|
case '<':
|
||||||
|
return value < expected;
|
||||||
|
case '<=':
|
||||||
|
return value <= expected;
|
||||||
|
case '>':
|
||||||
|
return value > expected;
|
||||||
|
case '>=':
|
||||||
|
return value >= expected;
|
||||||
|
default:
|
||||||
|
return value === expected;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compareGridValues = (left: string | number | null, right: string | number | null): number => {
|
||||||
|
if (left === right) return 0;
|
||||||
|
if (left === null) return 1;
|
||||||
|
if (right === null) return -1;
|
||||||
|
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
||||||
|
return String(left).localeCompare(String(right), 'ko-KR', { numeric: true });
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export interface TournamentBracketParticipant {
|
export interface TournamentBracketParticipant {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
picture?: string | null;
|
||||||
|
imageServer?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TournamentBracketMatch {
|
export interface TournamentBracketMatch {
|
||||||
@@ -15,6 +17,8 @@ export interface TournamentBracketMatch {
|
|||||||
export interface TournamentBracketSlot {
|
export interface TournamentBracketSlot {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
picture: string | null;
|
||||||
|
imageServer: number;
|
||||||
advanced: boolean;
|
advanced: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +35,13 @@ export interface TournamentBracketModel {
|
|||||||
top16: TournamentBracketRound;
|
top16: TournamentBracketRound;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptySlot = (): TournamentBracketSlot => ({ id: null, name: '-', advanced: false });
|
const emptySlot = (): TournamentBracketSlot => ({
|
||||||
|
id: null,
|
||||||
|
name: '-',
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
advanced: false,
|
||||||
|
});
|
||||||
|
|
||||||
export const buildTournamentBracket = (
|
export const buildTournamentBracket = (
|
||||||
participants: TournamentBracketParticipant[],
|
participants: TournamentBracketParticipant[],
|
||||||
@@ -39,19 +49,24 @@ export const buildTournamentBracket = (
|
|||||||
winnerId?: number
|
winnerId?: number
|
||||||
): TournamentBracketModel => {
|
): TournamentBracketModel => {
|
||||||
const participantsById = new Map(participants.map((participant) => [participant.id, participant]));
|
const participantsById = new Map(participants.map((participant) => [participant.id, participant]));
|
||||||
const nameOf = (id: number | null): string =>
|
const participantOf = (id: number | null): TournamentBracketParticipant | null =>
|
||||||
id === null ? '-' : (participantsById.get(id)?.name ?? `#${id}`);
|
id === null ? null : (participantsById.get(id) ?? { id, name: `#${id}` });
|
||||||
|
|
||||||
const buildRound = (stage: number, slotCount: number): TournamentBracketRound => {
|
const buildRound = (stage: number, slotCount: number): TournamentBracketRound => {
|
||||||
const roundMatches = matches
|
const roundMatches = matches
|
||||||
.filter((match) => match.stage === stage)
|
.filter((match) => match.stage === stage)
|
||||||
.sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id);
|
.sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id);
|
||||||
const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) =>
|
const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) =>
|
||||||
[match.attackerId, match.defenderId].map((id) => ({
|
[match.attackerId, match.defenderId].map((id) => {
|
||||||
id,
|
const participant = participantOf(id);
|
||||||
name: nameOf(id),
|
return {
|
||||||
advanced: match.winnerId === id,
|
id,
|
||||||
}))
|
name: participant?.name ?? '-',
|
||||||
|
picture: participant?.picture ?? null,
|
||||||
|
imageServer: participant?.imageServer ?? 0,
|
||||||
|
advanced: match.winnerId === id,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
while (slots.length < slotCount) {
|
while (slots.length < slotCount) {
|
||||||
slots.push(emptySlot());
|
slots.push(emptySlot());
|
||||||
@@ -65,7 +80,9 @@ export const buildTournamentBracket = (
|
|||||||
return {
|
return {
|
||||||
champion: {
|
champion: {
|
||||||
id: resolvedWinnerId,
|
id: resolvedWinnerId,
|
||||||
name: nameOf(resolvedWinnerId),
|
name: participantOf(resolvedWinnerId)?.name ?? '-',
|
||||||
|
picture: participantOf(resolvedWinnerId)?.picture ?? null,
|
||||||
|
imageServer: participantOf(resolvedWinnerId)?.imageServer ?? 0,
|
||||||
advanced: resolvedWinnerId !== null,
|
advanced: resolvedWinnerId !== null,
|
||||||
},
|
},
|
||||||
final,
|
final,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const tournamentStageNames = [
|
||||||
|
'경기 없음',
|
||||||
|
'참가 모집중',
|
||||||
|
'예선 진행중',
|
||||||
|
'본선 추첨중',
|
||||||
|
'본선 진행중',
|
||||||
|
'16강 배정중',
|
||||||
|
'베팅 진행중',
|
||||||
|
'16강 진행중',
|
||||||
|
'8강 진행중',
|
||||||
|
'4강 진행중',
|
||||||
|
'결승 진행중',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const resolveTournamentStageName = (stage: number): string => tournamentStageNames[stage] ?? '상태 확인 중';
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
@@ -41,24 +42,10 @@ const formatNumber = (value: number | null | undefined): string => (value ?? 0).
|
|||||||
const displayCode = (value: string | null | undefined): string =>
|
const displayCode = (value: string | null | undefined): string =>
|
||||||
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
|
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
|
||||||
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
|
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
|
||||||
if (!value) {
|
return formatServerDateTime(value, {
|
||||||
return '-';
|
format: showSecond ? 'monthDayTimeSeconds' : 'monthDayTime',
|
||||||
}
|
fallback: '-',
|
||||||
const date = new Date(value);
|
});
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return value.slice(5, showSecond ? 19 : 16);
|
|
||||||
}
|
|
||||||
const parts = new Intl.DateTimeFormat('ko-KR', {
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
...(showSecond ? { second: '2-digit' } : {}),
|
|
||||||
hour12: false,
|
|
||||||
}).formatToParts(date);
|
|
||||||
const part = (type: Intl.DateTimeFormatPartTypes): string =>
|
|
||||||
parts.find((entry) => entry.type === type)?.value ?? '';
|
|
||||||
return `${part('month')}-${part('day')} ${part('hour')}:${part('minute')}${showSecond ? `:${part('second')}` : ''}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buyRice = computed(() =>
|
const buyRice = computed(() =>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
import PanelCard from '../components/ui/PanelCard.vue';
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||||
|
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { getNpcColor } from '../utils/npcColor';
|
import { getNpcColor } from '../utils/npcColor';
|
||||||
import { formatLog } from '../utils/formatLog';
|
import { formatLog } from '../utils/formatLog';
|
||||||
import { resolveGeneralIconUrl } from '../utils/generalIcon';
|
|
||||||
|
|
||||||
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
|
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
|
||||||
type GeneralEntry = BattleCenterResponse['generals'][number];
|
type GeneralEntry = BattleCenterResponse['generals'][number];
|
||||||
@@ -126,9 +127,9 @@ const selectedGeneral = computed(() => {
|
|||||||
|
|
||||||
const formatGeneralLabel = (general: GeneralEntry): string => {
|
const formatGeneralLabel = (general: GeneralEntry): string => {
|
||||||
const name = general.officerLevel > 4 ? `*${general.name}*` : general.name;
|
const name = general.officerLevel > 4 ? `*${general.name}*` : general.name;
|
||||||
const time = general.turnTime ? general.turnTime.slice(-5) : '--:--';
|
const time = formatServerDateTime(general.turnTime, { format: 'hourMinute', fallback: '--:--' });
|
||||||
if (orderBy.value === 'recentWar') {
|
if (orderBy.value === 'recentWar') {
|
||||||
return `${name} (${general.recentWar ? general.recentWar.slice(-5) : '--:--'})`;
|
return `${name} (${formatServerDateTime(general.recentWar, { format: 'hourMinute', fallback: '--:--' })})`;
|
||||||
}
|
}
|
||||||
if (orderBy.value === 'warnum') {
|
if (orderBy.value === 'warnum') {
|
||||||
return `${name} (${general.warnum}회)`;
|
return `${name} (${general.warnum}회)`;
|
||||||
@@ -136,8 +137,6 @@ const formatGeneralLabel = (general: GeneralEntry): string => {
|
|||||||
return `${name} (${time})`;
|
return `${name} (${time})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const generalImageUrl = (general: GeneralEntry): string => resolveGeneralIconUrl(general);
|
|
||||||
|
|
||||||
let logRequestId = 0;
|
let logRequestId = 0;
|
||||||
|
|
||||||
const loadLogs = async (generalId: number) => {
|
const loadLogs = async (generalId: number) => {
|
||||||
@@ -156,7 +155,10 @@ const loadLogs = async (generalId: number) => {
|
|||||||
}
|
}
|
||||||
for (const response of responses) {
|
for (const response of responses) {
|
||||||
const formatted = response.logs.map((entry) => {
|
const formatted = response.logs.map((entry) => {
|
||||||
const eventTime = response.type === 'generalAction' ? ` ${entry.createdAt.slice(-8, -3)}` : '';
|
const eventTime =
|
||||||
|
response.type === 'generalAction'
|
||||||
|
? ` ${formatServerDateTime(entry.createdAt, { format: 'hourMinute' })}`
|
||||||
|
: '';
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
html: formatLog(`${entry.text}${eventTime}`),
|
html: formatLog(`${entry.text}${eventTime}`),
|
||||||
@@ -266,49 +268,36 @@ onMounted(() => {
|
|||||||
</PanelCard>
|
</PanelCard>
|
||||||
|
|
||||||
<PanelCard title="장수 정보">
|
<PanelCard title="장수 정보">
|
||||||
<SkeletonLines v-if="loading" :lines="5" />
|
<GeneralBasicCard
|
||||||
<div v-else-if="selectedGeneral" class="battle-general-card">
|
class="battle-general-card"
|
||||||
<div class="battle-general-name">
|
:general="selectedGeneral"
|
||||||
{{ selectedGeneral.name }} ({{ selectedGeneral.officerLevelText }})
|
:loading="loading"
|
||||||
</div>
|
:nation-color="data?.nation.color"
|
||||||
<span
|
>
|
||||||
class="battle-general-portrait"
|
<template v-if="selectedGeneral" #details>
|
||||||
role="img"
|
<div class="battle-general-extra">
|
||||||
:aria-label="`${selectedGeneral.name} 초상`"
|
<span>명성</span
|
||||||
:style="{ backgroundImage: `url(${generalImageUrl(selectedGeneral)})` }"
|
><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
|
||||||
/>
|
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
|
||||||
<div class="battle-general-grid">
|
<span>전투</span><strong>{{ selectedGeneral.warnum }}회</strong> <span>승리</span
|
||||||
<span>통솔</span><strong>{{ selectedGeneral.stats.leadership }}</strong> <span>무력</span
|
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
|
||||||
><strong>{{ selectedGeneral.stats.strength }}</strong> <span>지력</span
|
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>계략</span
|
||||||
><strong>{{ selectedGeneral.stats.intelligence }}</strong> <span>자금</span
|
><strong>{{ selectedGeneral.battleStats.fire }}</strong> <span>사살</span
|
||||||
><strong>{{ selectedGeneral.gold }}</strong> <span>군량</span
|
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
|
||||||
><strong>{{ selectedGeneral.rice }}</strong> <span>병력</span
|
<span>피살</span
|
||||||
><strong>{{ selectedGeneral.crew }}</strong> <span>훈련</span
|
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
|
||||||
><strong>{{ selectedGeneral.train }}</strong> <span>사기</span
|
<span>최근 전투</span><strong>{{ selectedGeneral.recentWar || '-' }}</strong>
|
||||||
><strong>{{ selectedGeneral.atmos }}</strong> <span>부상</span
|
</div>
|
||||||
><strong>{{ selectedGeneral.injury }}</strong> <span>경험</span
|
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
|
||||||
><strong>{{ selectedGeneral.experience }}</strong> <span>공헌</span
|
</template>
|
||||||
><strong>{{ selectedGeneral.dedication }}</strong> <span>전투</span
|
</GeneralBasicCard>
|
||||||
><strong>{{ selectedGeneral.warnum }}회</strong>
|
|
||||||
</div>
|
|
||||||
<div class="battle-general-extra">
|
|
||||||
<span>명성</span><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
|
|
||||||
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
|
|
||||||
<span>나이</span><strong>{{ selectedGeneral.age }}세</strong> <span>병종</span
|
|
||||||
><strong>{{ selectedGeneral.crewTypeName }}</strong> <span>승리</span
|
|
||||||
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
|
|
||||||
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>사살</span
|
|
||||||
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
|
|
||||||
<span>피살</span
|
|
||||||
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
|
|
||||||
<span>전투 특기</span><strong>{{ selectedGeneral.traits.specialWar }}</strong>
|
|
||||||
<span>내정 특기</span><strong>{{ selectedGeneral.traits.specialDomestic }}</strong>
|
|
||||||
<span>성격</span><strong>{{ selectedGeneral.traits.personal }}</strong>
|
|
||||||
</div>
|
|
||||||
<LegacyGeneralProgress :general="selectedGeneral" />
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedGeneral" class="general-meta">
|
<div v-if="selectedGeneral" class="general-meta">
|
||||||
<div>최근 턴: {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div>
|
<div>
|
||||||
|
최근 턴:
|
||||||
|
{{
|
||||||
|
formatServerDateTime(selectedGeneral.turnTime, { format: 'hourMinute', fallback: '-' })
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
|
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
|
||||||
<div>전투 횟수: {{ selectedGeneral.warnum }}</div>
|
<div>전투 횟수: {{ selectedGeneral.warnum }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -375,39 +364,7 @@ onMounted(() => {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-general-card {
|
|
||||||
min-height: 292px;
|
|
||||||
position: relative;
|
|
||||||
background-color: #172a52;
|
|
||||||
background-image: var(--sammo-texture-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.battle-general-portrait {
|
|
||||||
display: block;
|
|
||||||
width: 64px;
|
|
||||||
height: 80px;
|
|
||||||
float: left;
|
|
||||||
background-position: center;
|
|
||||||
background-size: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.battle-general-name {
|
|
||||||
min-height: 24px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
text-align: center;
|
|
||||||
border-bottom: 1px solid #777;
|
|
||||||
background: rgba(220, 220, 220, 0.85);
|
|
||||||
color: #111;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.battle-general-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(6, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.battle-general-extra {
|
.battle-general-extra {
|
||||||
clear: both;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
}
|
}
|
||||||
@@ -433,24 +390,6 @@ onMounted(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-general-grid > * {
|
|
||||||
min-height: 24px;
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-right: 1px solid #777;
|
|
||||||
border-bottom: 1px solid #777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.battle-general-grid > span {
|
|
||||||
background-color: rgba(20, 75, 42, 0.7);
|
|
||||||
color: #fff;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.battle-general-grid > strong {
|
|
||||||
text-align: right;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-grid {
|
.log-grid {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||||
|
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||||
@@ -12,6 +14,7 @@ const loading = ref(false);
|
|||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const message = ref<string | null>(null);
|
const message = ref<string | null>(null);
|
||||||
const amounts = ref<Record<number, number>>({});
|
const amounts = ref<Record<number, number>>({});
|
||||||
|
const activeRankingPrefix = ref('tt');
|
||||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||||
const stageNames = [
|
const stageNames = [
|
||||||
'경기 없음',
|
'경기 없음',
|
||||||
@@ -57,7 +60,13 @@ const final16Ids = computed(() =>
|
|||||||
const candidates = computed(() =>
|
const candidates = computed(() =>
|
||||||
Array.from({ length: 16 }, (_, index) => {
|
Array.from({ length: 16 }, (_, index) => {
|
||||||
const id = final16Ids.value[index] ?? 0;
|
const id = final16Ids.value[index] ?? 0;
|
||||||
return { id, name: id ? (participantMap.value.get(id)?.name ?? `#${id}`) : '-' };
|
const participant = id ? participantMap.value.get(id) : null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id ? (participant?.name ?? `#${id}`) : '-',
|
||||||
|
picture: participant?.picture ?? null,
|
||||||
|
imageServer: participant?.imageServer ?? 0,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
||||||
@@ -68,7 +77,9 @@ const ratio = (id: number) => {
|
|||||||
const amount = totals?.[id] ?? 0;
|
const amount = totals?.[id] ?? 0;
|
||||||
return amount ? (totalAmount.value / amount).toFixed(2) : '0';
|
return amount ? (totalAmount.value / amount).toFixed(2) : '0';
|
||||||
};
|
};
|
||||||
const openingTime = computed(() => snapshot.value?.state?.nextAt?.slice(11, 16) ?? '--:--');
|
const openingTime = computed(() =>
|
||||||
|
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
||||||
|
);
|
||||||
const expected = (id: number) => {
|
const expected = (id: number) => {
|
||||||
const myTotals = summary.value?.myTotals as Record<number, number> | undefined;
|
const myTotals = summary.value?.myTotals as Record<number, number> | undefined;
|
||||||
const current = myTotals?.[id] ?? 0;
|
const current = myTotals?.[id] ?? 0;
|
||||||
@@ -129,58 +140,43 @@ const placeBet = async (targetId: number) => {
|
|||||||
:bet-totals="betTotals"
|
:bet-totals="betTotals"
|
||||||
:total-bet="totalAmount"
|
:total-bet="totalAmount"
|
||||||
:show-legend="false"
|
:show-legend="false"
|
||||||
force-desktop
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="candidate-table bg0">
|
<section class="candidate-table bg0">
|
||||||
<div class="candidate-row names">
|
<div class="candidate-grid">
|
||||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{ candidate.name }}</span>
|
<article v-for="candidate in candidates" :key="candidate.id || candidate.name" class="candidate-card">
|
||||||
|
<GeneralIdentity
|
||||||
|
:name="candidate.name"
|
||||||
|
:picture="candidate.picture"
|
||||||
|
:image-server="candidate.imageServer"
|
||||||
|
:icon-size="36"
|
||||||
|
/>
|
||||||
|
<div class="candidate-return">
|
||||||
|
<span class="ratio-color">{{ ratio(candidate.id) }}</span>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
<span class="gold-color">{{ amounts[candidate.id] ?? 10 }}</span>
|
||||||
|
<span aria-hidden="true">=</span>
|
||||||
|
<strong class="return-color">{{ expected(candidate.id) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="bettingOpen" class="candidate-actions">
|
||||||
|
<select
|
||||||
|
v-model.number="amounts[candidate.id]"
|
||||||
|
:aria-label="`${candidate.name} 베팅 금액`"
|
||||||
|
:disabled="!candidate.id"
|
||||||
|
>
|
||||||
|
<option :value="10">금10</option>
|
||||||
|
<option :value="20">금20</option>
|
||||||
|
<option :value="50">금50</option>
|
||||||
|
<option :value="100">금100</option>
|
||||||
|
<option :value="200">금200</option>
|
||||||
|
<option :value="500">금500</option>
|
||||||
|
<option :value="1000">최대</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" :disabled="!candidate.id" @click="placeBet(candidate.id)">베팅</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div class="candidate-row ratios">
|
<p class="candidate-help">
|
||||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
|
|
||||||
ratio(candidate.id)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="candidate-row multiply">
|
|
||||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">×</span>
|
|
||||||
</div>
|
|
||||||
<div class="candidate-row labels">
|
|
||||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">∥</span>
|
|
||||||
</div>
|
|
||||||
<div class="candidate-row expected">
|
|
||||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
|
|
||||||
expected(candidate.id)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="bettingOpen" class="candidate-row selects">
|
|
||||||
<select
|
|
||||||
v-for="candidate in candidates"
|
|
||||||
:key="candidate.id || candidate.name"
|
|
||||||
v-model.number="amounts[candidate.id]"
|
|
||||||
:aria-label="`${candidate.name} 베팅 금액`"
|
|
||||||
:disabled="!candidate.id"
|
|
||||||
>
|
|
||||||
<option :value="10">금10</option>
|
|
||||||
<option :value="20">금20</option>
|
|
||||||
<option :value="50">금50</option>
|
|
||||||
<option :value="100">금100</option>
|
|
||||||
<option :value="200">금200</option>
|
|
||||||
<option :value="500">금500</option>
|
|
||||||
<option :value="1000">최대</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div v-if="bettingOpen" class="candidate-row buttons">
|
|
||||||
<button
|
|
||||||
v-for="candidate in candidates"
|
|
||||||
:key="candidate.id || candidate.name"
|
|
||||||
type="button"
|
|
||||||
:disabled="!candidate.id"
|
|
||||||
@click="placeBet(candidate.id)"
|
|
||||||
>
|
|
||||||
베팅!
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
|
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
|
||||||
<span class="return-color">적중시 환수금</span><br />
|
<span class="return-color">적중시 환수금</span><br />
|
||||||
<span class="ratio-color">( 베팅후 500원 이하일땐 베팅이 불가능합니다. )</span>
|
<span class="ratio-color">( 베팅후 500원 이하일땐 베팅이 불가능합니다. )</span>
|
||||||
@@ -201,8 +197,26 @@ const placeBet = async (targetId: number) => {
|
|||||||
<section class="ranking-placeholder bg0">
|
<section class="ranking-placeholder bg0">
|
||||||
순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
|
순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
|
||||||
</section>
|
</section>
|
||||||
|
<div class="ranking-tabs bg0" role="tablist" aria-label="토너먼트 랭킹 종목 선택">
|
||||||
|
<button
|
||||||
|
v-for="section in rankings"
|
||||||
|
:key="`ranking-tab-${section.prefix}`"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeRankingPrefix === section.prefix"
|
||||||
|
:class="{ active: activeRankingPrefix === section.prefix }"
|
||||||
|
@click="activeRankingPrefix = section.prefix"
|
||||||
|
>
|
||||||
|
{{ section.title.replaceAll(' ', '') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<section class="ranking-grid bg0">
|
<section class="ranking-grid bg0">
|
||||||
<table v-for="section in rankings" :key="section.prefix" class="ranking-table">
|
<table
|
||||||
|
v-for="section in rankings"
|
||||||
|
:key="section.prefix"
|
||||||
|
class="ranking-table"
|
||||||
|
:class="{ 'mobile-active': activeRankingPrefix === section.prefix }"
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="9">{{ section.title }}</th>
|
<th colspan="9">{{ section.title }}</th>
|
||||||
@@ -222,7 +236,14 @@ const placeBet = async (targetId: number) => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="entry in section.entries" :key="entry.generalId">
|
<tr v-for="entry in section.entries" :key="entry.generalId">
|
||||||
<td>{{ entry.rank }}</td>
|
<td>{{ entry.rank }}</td>
|
||||||
<td>{{ entry.name }}</td>
|
<td class="ranking-general">
|
||||||
|
<GeneralIdentity
|
||||||
|
:name="entry.name"
|
||||||
|
:picture="entry.picture"
|
||||||
|
:image-server="entry.imageServer"
|
||||||
|
:icon-size="24"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td>{{ entry.stat }}</td>
|
<td>{{ entry.stat }}</td>
|
||||||
<td>{{ entry.games }}</td>
|
<td>{{ entry.games }}</td>
|
||||||
<td>{{ entry.win }}</td>
|
<td>{{ entry.win }}</td>
|
||||||
@@ -248,8 +269,7 @@ const placeBet = async (targetId: number) => {
|
|||||||
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<small>
|
<small>
|
||||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
|
||||||
HideD(hided62@gmail.com) / Credit
|
|
||||||
</small>
|
</small>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
@@ -257,9 +277,10 @@ const placeBet = async (targetId: number) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.betting-page {
|
.betting-page {
|
||||||
width: 1125px;
|
width: 100%;
|
||||||
height: 1346px;
|
max-width: 1200px;
|
||||||
overflow: hidden;
|
min-width: 0;
|
||||||
|
min-height: 100vh;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: var(--sammo-font-sans);
|
font-family: var(--sammo-font-sans);
|
||||||
@@ -268,8 +289,8 @@ const placeBet = async (targetId: number) => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.betting-bracket :deep(.bracket-canvas) {
|
.betting-bracket :deep(.bracket-canvas) {
|
||||||
width: 1125px;
|
width: 100%;
|
||||||
min-width: 1125px;
|
min-width: 1000px;
|
||||||
}
|
}
|
||||||
.betting-bracket :deep(.bracket-round),
|
.betting-bracket :deep(.bracket-round),
|
||||||
.betting-bracket :deep(.connector-row) {
|
.betting-bracket :deep(.connector-row) {
|
||||||
@@ -351,20 +372,34 @@ const placeBet = async (targetId: number) => {
|
|||||||
}
|
}
|
||||||
.candidate-table {
|
.candidate-table {
|
||||||
border: 1px solid gray;
|
border: 1px solid gray;
|
||||||
padding: 10px 0;
|
padding: 10px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.candidate-row {
|
.candidate-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(16, 70px);
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
align-items: center;
|
gap: 8px;
|
||||||
min-height: 10px;
|
|
||||||
line-height: 10px;
|
|
||||||
}
|
}
|
||||||
.names {
|
.candidate-card {
|
||||||
min-height: 14px;
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #5b504b;
|
||||||
|
background: rgb(0 0 0 / 26%);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.candidate-return {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr auto 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 8px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.candidate-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 64px;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.ratios,
|
|
||||||
.ratio-color {
|
.ratio-color {
|
||||||
color: skyblue;
|
color: skyblue;
|
||||||
}
|
}
|
||||||
@@ -376,7 +411,7 @@ const placeBet = async (targetId: number) => {
|
|||||||
color: orange;
|
color: orange;
|
||||||
}
|
}
|
||||||
select,
|
select,
|
||||||
.buttons button {
|
.candidate-actions button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 27px;
|
min-height: 27px;
|
||||||
padding: 2px 1px;
|
padding: 2px 1px;
|
||||||
@@ -410,7 +445,7 @@ select:disabled {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
.candidate-table p {
|
.candidate-help {
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
@@ -429,11 +464,13 @@ select:disabled {
|
|||||||
}
|
}
|
||||||
.ranking-grid {
|
.ranking-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 280px);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
}
|
}
|
||||||
.ranking-table {
|
.ranking-table {
|
||||||
width: 280px;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -441,7 +478,7 @@ select:disabled {
|
|||||||
}
|
}
|
||||||
.ranking-table th,
|
.ranking-table th,
|
||||||
.ranking-table td {
|
.ranking-table td {
|
||||||
height: 14px;
|
height: 28px;
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
}
|
}
|
||||||
@@ -455,12 +492,20 @@ select:disabled {
|
|||||||
.ranking-table .bg1 {
|
.ranking-table .bg1 {
|
||||||
background: #213b52;
|
background: #213b52;
|
||||||
}
|
}
|
||||||
|
.ranking-table th:nth-child(2),
|
||||||
.ranking-table td:nth-child(2) {
|
.ranking-table td:nth-child(2) {
|
||||||
max-width: 80px;
|
width: 130px;
|
||||||
|
max-width: 130px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.ranking-general {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.ranking-tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.guide {
|
.guide {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -468,4 +513,83 @@ select:disabled {
|
|||||||
.error {
|
.error {
|
||||||
color: #ff8080;
|
color: #ff8080;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.betting-page {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
height: auto;
|
||||||
|
min-height: 55px;
|
||||||
|
}
|
||||||
|
.state {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.section-title,
|
||||||
|
.ranking-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.candidate-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.candidate-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 112px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
.candidate-return {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.candidate-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.candidate-help {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.ranking-placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ranking-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 5px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.ranking-tabs button {
|
||||||
|
height: 36px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.ranking-tabs button.active {
|
||||||
|
border-color: #f39c12;
|
||||||
|
background: #8a5b13;
|
||||||
|
}
|
||||||
|
.ranking-grid {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.ranking-table {
|
||||||
|
display: none;
|
||||||
|
min-width: 390px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.ranking-table.mobile-active {
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
.ranking-table th:nth-child(2),
|
||||||
|
.ranking-table td:nth-child(2) {
|
||||||
|
width: 112px;
|
||||||
|
max-width: 112px;
|
||||||
|
}
|
||||||
|
.guide,
|
||||||
|
.betting-footer {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.betting-footer small {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ const resizeTextArea = (element: HTMLTextAreaElement | null) => {
|
|||||||
element.style.height = `${Math.max(element.scrollHeight, 42)}px`;
|
element.style.height = `${Math.max(element.scrollHeight, 42)}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (value: string): string => value.slice(5, 16).replace('T', ' ');
|
const formatDate = (value: string): string => formatServerDateTime(value, { format: 'monthDayTime' });
|
||||||
|
|
||||||
const iconPath = (article: BoardArticle): string =>
|
const iconPath = (article: BoardArticle): string =>
|
||||||
resolveGeneralIconUrl({
|
resolveGeneralIconUrl({
|
||||||
@@ -160,7 +161,14 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="article-submit-row">
|
<div class="article-submit-row">
|
||||||
<div></div>
|
<div></div>
|
||||||
<button id="submitArticle" class="legacy-button legacy-button--secondary" type="button" @click="submitArticle">등록</button>
|
<button
|
||||||
|
id="submitArticle"
|
||||||
|
class="legacy-button legacy-button--secondary"
|
||||||
|
type="button"
|
||||||
|
@click="submitArticle"
|
||||||
|
>
|
||||||
|
등록
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -244,7 +252,9 @@ onMounted(() => {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
color: #000;
|
color: #000;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font: 16px/normal 'Times New Roman', serif;
|
font:
|
||||||
|
16px/normal 'Times New Roman',
|
||||||
|
serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legacy-board-page {
|
.legacy-board-page {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
|
|||||||
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
|
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
import { formatOfficerLevelText } from '../utils/nationFormat';
|
||||||
import type { CommandPatternEntry, CommandTable } from '../components/command/types';
|
import type { CommandMapData, CommandMapLayout, CommandPatternEntry, CommandTable } from '../components/command/types';
|
||||||
|
|
||||||
type ChiefTurn = {
|
type ChiefTurn = {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -54,6 +54,10 @@ const chiefApi = trpc as unknown as {
|
|||||||
query: (input: { generalId: number }) => Promise<CommandTable>;
|
query: (input: { generalId: number }) => Promise<CommandTable>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
world: {
|
||||||
|
getMap: { query: () => Promise<CommandMapData> };
|
||||||
|
getMapLayout: { query: () => Promise<CommandMapLayout> };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type TurnRow = {
|
type TurnRow = {
|
||||||
@@ -71,6 +75,8 @@ const commandLoading = ref(false);
|
|||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const data = ref<ChiefCenterResponse | null>(null);
|
const data = ref<ChiefCenterResponse | null>(null);
|
||||||
const commandTable = ref<CommandTable | null>(null);
|
const commandTable = ref<CommandTable | null>(null);
|
||||||
|
const worldMap = ref<CommandMapData | null>(null);
|
||||||
|
const mapLayout = ref<CommandMapLayout | null>(null);
|
||||||
|
|
||||||
const selectedChiefLevel = ref<number | null>(null);
|
const selectedChiefLevel = ref<number | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -109,7 +115,14 @@ const loadCommandTable = async (generalId: number) => {
|
|||||||
}
|
}
|
||||||
commandLoading.value = true;
|
commandLoading.value = true;
|
||||||
try {
|
try {
|
||||||
commandTable.value = await chiefApi.turns.getCommandTable.query({ generalId });
|
const [nextCommandTable, nextWorldMap, nextMapLayout] = await Promise.all([
|
||||||
|
chiefApi.turns.getCommandTable.query({ generalId }),
|
||||||
|
chiefApi.world.getMap.query().catch(() => null),
|
||||||
|
chiefApi.world.getMapLayout.query().catch(() => null),
|
||||||
|
]);
|
||||||
|
commandTable.value = nextCommandTable;
|
||||||
|
worldMap.value = nextWorldMap;
|
||||||
|
mapLayout.value = nextMapLayout;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -317,6 +330,8 @@ const repeatTurns = async (amount: number) => {
|
|||||||
:general-id="data.me.id"
|
:general-id="data.me.id"
|
||||||
:officer-level="selectedChief.officerLevel"
|
:officer-level="selectedChief.officerLevel"
|
||||||
:mobile="true"
|
:mobile="true"
|
||||||
|
:map-data="worldMap"
|
||||||
|
:map-layout="mapLayout"
|
||||||
@reserve-bulk="reserveTurns"
|
@reserve-bulk="reserveTurns"
|
||||||
@shift="shiftTurns"
|
@shift="shiftTurns"
|
||||||
@repeat="repeatTurns"
|
@repeat="repeatTurns"
|
||||||
@@ -377,6 +392,8 @@ const repeatTurns = async (amount: number) => {
|
|||||||
:loading="commandLoading"
|
:loading="commandLoading"
|
||||||
:general-id="data.me.id"
|
:general-id="data.me.id"
|
||||||
:officer-level="chief.officerLevel"
|
:officer-level="chief.officerLevel"
|
||||||
|
:map-data="worldMap"
|
||||||
|
:map-layout="mapLayout"
|
||||||
@reserve-bulk="reserveTurns"
|
@reserve-bulk="reserveTurns"
|
||||||
@shift="shiftTurns"
|
@shift="shiftTurns"
|
||||||
@repeat="repeatTurns"
|
@repeat="repeatTurns"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
@@ -47,12 +48,7 @@ const loadDetail = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatArchiveDate = (value: string): string =>
|
const formatArchiveDate = (value: string): string => formatServerDateTime(value);
|
||||||
new Intl.DateTimeFormat('sv-SE', {
|
|
||||||
dateStyle: 'short',
|
|
||||||
timeStyle: 'medium',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
}).format(new Date(value));
|
|
||||||
|
|
||||||
watch(emperorId, loadDetail);
|
watch(emperorId, loadDetail);
|
||||||
onMounted(loadDetail);
|
onMounted(loadDetail);
|
||||||
@@ -67,7 +63,9 @@ onMounted(loadDetail);
|
|||||||
역 대 왕 조<br />
|
역 대 왕 조<br />
|
||||||
<button class="native-button" type="button" @click="closePage">창 닫기</button>
|
<button class="native-button" type="button" @click="closePage">창 닫기</button>
|
||||||
<span class="all-link">
|
<span class="all-link">
|
||||||
<RouterLink to="/dynasty"><button class="native-button" type="button">전체보기</button></RouterLink>
|
<RouterLink to="/dynasty"
|
||||||
|
><button class="native-button" type="button">전체보기</button></RouterLink
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -202,7 +200,11 @@ onMounted(loadDetail);
|
|||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
<!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. -->
|
<!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. -->
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<div v-for="(entry, index) in data.emperor.history" :key="index" v-html="formatLog(entry)" />
|
<div
|
||||||
|
v-for="(entry, index) in data.emperor.history"
|
||||||
|
:key="index"
|
||||||
|
v-html="formatLog(entry)"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -283,14 +285,10 @@ onMounted(loadDetail);
|
|||||||
<table class="legacy-table legacy-bg0 footer-table">
|
<table class="legacy-table legacy-bg0 footer-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td><button class="native-button" type="button" @click="closePage">창 닫기</button><br /></td>
|
||||||
<button class="native-button" type="button" @click="closePage">창 닫기</button><br />
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="banner">
|
<td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
|
||||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
@@ -169,11 +170,7 @@ const turnTimeLabel = computed(() => {
|
|||||||
if (!turnTimeResult.value) {
|
if (!turnTimeResult.value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parsed = new Date(turnTimeResult.value);
|
return formatServerDateTime(turnTimeResult.value);
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
|
||||||
return turnTimeResult.value;
|
|
||||||
}
|
|
||||||
return parsed.toLocaleString();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isUnited = computed(() => status.value?.isUnited ?? false);
|
const isUnited = computed(() => status.value?.isUnited ?? false);
|
||||||
@@ -735,7 +732,7 @@ onMounted(() => {
|
|||||||
<div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
|
<div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
|
||||||
<div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
|
<div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
|
||||||
<div v-for="entry in logs" v-else :key="entry.id" class="log-row">
|
<div v-for="entry in logs" v-else :key="entry.id" class="log-row">
|
||||||
<small>[{{ new Date(entry.createdAt).toLocaleString('ko-KR') }}]</small>
|
<small>[{{ formatServerDateTime(entry.createdAt) }}]</small>
|
||||||
<span>{{ entry.text }}</span>
|
<span>{{ entry.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useMediaQuery } from '@vueuse/core';
|
import { useMediaQuery } from '@vueuse/core';
|
||||||
@@ -68,14 +69,8 @@ const nationColor = computed(() => nation.value?.color ?? '#000000');
|
|||||||
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
|
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
|
||||||
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 || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text);
|
||||||
const parsed = entry.createdAt ? new Date(entry.createdAt) : null;
|
const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' });
|
||||||
if (!parsed || Number.isNaN(parsed.getTime())) return formatLog(entry.text);
|
if (!time) return formatLog(entry.text);
|
||||||
const time = new Intl.DateTimeFormat('ko-KR', {
|
|
||||||
timeZone: 'Asia/Seoul',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
}).format(parsed);
|
|
||||||
return formatLog(`${entry.text} ${time}`);
|
return formatLog(`${entry.text} ${time}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,13 +143,9 @@ watch(
|
|||||||
<header class="game-shell__header">
|
<header class="game-shell__header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="game-shell__title">
|
<h1 class="game-shell__title">
|
||||||
{{ isMobile ? '전장 현황' : lobbyInfo?.scenarioTitle || '전장 현황' }}
|
{{ lobbyInfo?.scenarioTitle || '전장 현황' }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="game-shell__subtitle">
|
<p class="game-shell__subtitle">{{ statusLine }}</p>
|
||||||
{{
|
|
||||||
!isMobile && lobbyInfo?.scenarioTitle ? `${lobbyInfo.scenarioTitle} ${statusLine}` : statusLine
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="game-shell__actions desktop-action-controls">
|
<div class="game-shell__actions desktop-action-controls">
|
||||||
<button
|
<button
|
||||||
@@ -197,7 +188,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-main-target="policy">
|
<div data-main-target="policy">
|
||||||
<MainFrontStatus :status="frontStatus" />
|
<MainFrontStatus :status="frontStatus" :tournament-stage="tournamentStage" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
|
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
|
||||||
@@ -220,6 +211,8 @@ watch(
|
|||||||
:current-month="lobbyInfo?.month"
|
:current-month="lobbyInfo?.month"
|
||||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||||
:autorun-limit="reservedGeneralAutorunLimit"
|
:autorun-limit="reservedGeneralAutorunLimit"
|
||||||
|
:map-data="worldMap"
|
||||||
|
:map-layout="mapLayout"
|
||||||
@set-general-turns="reserveGeneralTurns"
|
@set-general-turns="reserveGeneralTurns"
|
||||||
@shift-general-turns="shiftGeneralTurns"
|
@shift-general-turns="shiftGeneralTurns"
|
||||||
@repeat-general-turns="repeatGeneralTurns"
|
@repeat-general-turns="repeatGeneralTurns"
|
||||||
@@ -241,7 +234,12 @@ watch(
|
|||||||
<NationBasicCard :nation="nation" :loading="loading" />
|
<NationBasicCard :nation="nation" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="장수 스탯" data-main-target="general">
|
<PanelCard title="장수 스탯" data-main-target="general">
|
||||||
<GeneralBasicCard :general="general" :loading="loading" />
|
<GeneralBasicCard
|
||||||
|
:general="general"
|
||||||
|
:loading="loading"
|
||||||
|
:nation-color="nation?.color"
|
||||||
|
:troop-text="general?.troopId ? String(general.troopId) : '-'"
|
||||||
|
/>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="도시 정보" data-main-target="city">
|
<PanelCard title="도시 정보" data-main-target="city">
|
||||||
<CityBasicCard :city="city" :loading="loading" />
|
<CityBasicCard :city="city" :loading="loading" />
|
||||||
@@ -344,6 +342,8 @@ watch(
|
|||||||
:current-month="lobbyInfo?.month"
|
:current-month="lobbyInfo?.month"
|
||||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||||
:autorun-limit="reservedGeneralAutorunLimit"
|
:autorun-limit="reservedGeneralAutorunLimit"
|
||||||
|
:map-data="worldMap"
|
||||||
|
:map-layout="mapLayout"
|
||||||
@set-general-turns="reserveGeneralTurns"
|
@set-general-turns="reserveGeneralTurns"
|
||||||
@shift-general-turns="shiftGeneralTurns"
|
@shift-general-turns="shiftGeneralTurns"
|
||||||
@repeat-general-turns="repeatGeneralTurns"
|
@repeat-general-turns="repeatGeneralTurns"
|
||||||
@@ -356,7 +356,12 @@ watch(
|
|||||||
<NationBasicCard :nation="nation" :loading="loading" />
|
<NationBasicCard :nation="nation" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="장수 스탯" data-main-target="general">
|
<PanelCard title="장수 스탯" data-main-target="general">
|
||||||
<GeneralBasicCard :general="general" :loading="loading" />
|
<GeneralBasicCard
|
||||||
|
:general="general"
|
||||||
|
:loading="loading"
|
||||||
|
:nation-color="nation?.color"
|
||||||
|
:troop-text="general?.troopId ? String(general.troopId) : '-'"
|
||||||
|
/>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<MainNationMenu
|
<MainNationMenu
|
||||||
class="nation-menu-middle"
|
class="nation-menu-middle"
|
||||||
@@ -605,12 +610,14 @@ button {
|
|||||||
.layout-desktop > [data-main-target='nation'] {
|
.layout-desktop > [data-main-target='nation'] {
|
||||||
grid-column: 1 / 6;
|
grid-column: 1 / 6;
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
|
align-self: stretch;
|
||||||
min-height: 193px;
|
min-height: 193px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-desktop > [data-main-target='general'] {
|
.layout-desktop > [data-main-target='general'] {
|
||||||
grid-column: 6 / 11;
|
grid-column: 6 / 11;
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
|
align-self: stretch;
|
||||||
min-height: 193px;
|
min-height: 193px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
|
|||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||||
|
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
|
|
||||||
const SCREEN_MODE_KEY = 'sam.screenMode';
|
const SCREEN_MODE_KEY = 'sam.screenMode';
|
||||||
@@ -167,6 +168,7 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
const iconChoices = computed(() => data.value?.iconChoices ?? []);
|
const iconChoices = computed(() => data.value?.iconChoices ?? []);
|
||||||
|
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
|
||||||
|
|
||||||
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
|
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
|
||||||
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
|
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
|
||||||
@@ -360,7 +362,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
|
<main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
<span>내 정 보</span>
|
<span>내 정 보</span>
|
||||||
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
|
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
|
||||||
@@ -374,91 +376,43 @@ onMounted(() => {
|
|||||||
<section class="top-grid">
|
<section class="top-grid">
|
||||||
<div class="general-column">
|
<div class="general-column">
|
||||||
<div class="section-title sky">장수 정보</div>
|
<div class="section-title sky">장수 정보</div>
|
||||||
<div v-if="loading || !data" class="loading">불러오는 중...</div>
|
<GeneralBasicCard
|
||||||
<div v-else class="general-table">
|
class="general-table"
|
||||||
<div class="portrait-cell">
|
:general="data?.general ?? null"
|
||||||
<span
|
:loading="loading"
|
||||||
class="portrait-image"
|
:nation-color="data?.nation?.color"
|
||||||
role="img"
|
:defence-text="form.defence_train === 999 ? '수비 안함' : `수비 함(훈사${form.defence_train})`"
|
||||||
:style="{ backgroundImage: `url(${resolveGeneralIconUrl(data.general)})` }"
|
:troop-text="data?.general.troopId ? String(data.general.troopId) : '-'"
|
||||||
></span>
|
:penalty-text="penalties.length || '-'"
|
||||||
<strong>{{ data.general.name }}</strong>
|
>
|
||||||
</div>
|
<template v-if="data" #details>
|
||||||
<dl>
|
<div class="legacy-general-details">
|
||||||
<div>
|
<div>
|
||||||
<dt>통솔</dt>
|
명망
|
||||||
<dd>{{ data.general.stats.leadership }}</dd>
|
<strong
|
||||||
|
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
|
||||||
|
data.general.experience
|
||||||
|
}})</strong
|
||||||
|
>
|
||||||
|
· 계급
|
||||||
|
<strong
|
||||||
|
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
|
||||||
|
data.general.dedication
|
||||||
|
}})</strong
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div>전투 0 · 계략 0 · 사관 7년</div>
|
||||||
|
<div>승률 0% · 승리 0 · 패배 0</div>
|
||||||
|
<div>살상률 0% · 사살 0 · 피살 0</div>
|
||||||
|
<div>
|
||||||
|
소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종
|
||||||
|
{{ data.general.crewTypeName ?? '-' }} · 내정특기
|
||||||
|
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }}
|
||||||
|
</div>
|
||||||
|
<LegacyGeneralProgress :general="data.general" :show-primary="false" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</template>
|
||||||
<dt>무력</dt>
|
</GeneralBasicCard>
|
||||||
<dd>{{ data.general.stats.strength }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>지력</dt>
|
|
||||||
<dd>{{ data.general.stats.intelligence }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>소속</dt>
|
|
||||||
<dd>{{ data.nation?.name ?? '재야' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>도시</dt>
|
|
||||||
<dd>{{ data.city?.name ?? '-' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>금/쌀</dt>
|
|
||||||
<dd>{{ data.general.gold }} / {{ data.general.rice }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>병력</dt>
|
|
||||||
<dd>{{ data.general.crew }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>훈련/사기</dt>
|
|
||||||
<dd>{{ data.general.train }} / {{ data.general.atmos }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>경험/공헌</dt>
|
|
||||||
<dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>성격/특기</dt>
|
|
||||||
<dd>
|
|
||||||
{{ data.general.traits?.personal ?? '-' }} /
|
|
||||||
{{ data.general.traits?.specialWar ?? '-' }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>나이/다음턴</dt>
|
|
||||||
<dd>{{ data.general.age ?? '-' }}세 / {{ data.general.turnTime?.slice(11, 16) ?? '-' }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
<div v-if="data" class="legacy-general-details">
|
|
||||||
<div>
|
|
||||||
명망
|
|
||||||
<strong
|
|
||||||
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
|
|
||||||
data.general.experience
|
|
||||||
}})</strong
|
|
||||||
>
|
|
||||||
· 계급
|
|
||||||
<strong
|
|
||||||
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
|
|
||||||
data.general.dedication
|
|
||||||
}})</strong
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>전투 0 · 계략 0 · 사관 7년</div>
|
|
||||||
<div>승률 0% · 승리 0 · 패배 0</div>
|
|
||||||
<div>살상률 0% · 사살 0 · 피살 0</div>
|
|
||||||
<div>
|
|
||||||
병종 {{ data.general.crewTypeName ?? '-' }} · 내정특기
|
|
||||||
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대
|
|
||||||
{{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }}
|
|
||||||
</div>
|
|
||||||
<LegacyGeneralProgress :general="data.general" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-column">
|
<div class="settings-column">
|
||||||
@@ -541,6 +495,16 @@ onMounted(() => {
|
|||||||
<span v-if="data.iconChangeAvailableAt" class="hint">
|
<span v-if="data.iconChangeAvailableAt" class="hint">
|
||||||
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
|
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
|
||||||
</span>
|
</span>
|
||||||
|
<div v-if="selectedIcon" class="selected-general-icon" aria-live="polite">
|
||||||
|
<img
|
||||||
|
:src="resolveGeneralIconUrl(selectedIcon)"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
alt=""
|
||||||
|
@error="useDefaultGeneralIcon"
|
||||||
|
/>
|
||||||
|
<strong>{{ data.general.name }}</strong>
|
||||||
|
</div>
|
||||||
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
|
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
|
||||||
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
|
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
|
||||||
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
||||||
@@ -675,8 +639,7 @@ onMounted(() => {
|
|||||||
.legacy-page {
|
.legacy-page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
min-width: 500px;
|
min-width: 0;
|
||||||
height: 1257.5px;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -786,28 +749,6 @@ button:disabled {
|
|||||||
.sky {
|
.sky {
|
||||||
color: skyblue;
|
color: skyblue;
|
||||||
}
|
}
|
||||||
.general-table {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 150px 1fr;
|
|
||||||
padding: 0;
|
|
||||||
background-color: #172a52;
|
|
||||||
background-image: var(--sammo-texture-blue);
|
|
||||||
}
|
|
||||||
.portrait-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
border-right: 1px solid #777;
|
|
||||||
}
|
|
||||||
.portrait-image {
|
|
||||||
display: block;
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
background-position: center;
|
|
||||||
background-size: cover;
|
|
||||||
}
|
|
||||||
.legacy-general-info-compat {
|
.legacy-general-info-compat {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -824,23 +765,6 @@ button:disabled {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
dl {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
dl > div {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 80px 1fr;
|
|
||||||
border-bottom: 1px solid #777;
|
|
||||||
}
|
|
||||||
dt,
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-right: 1px solid #777;
|
|
||||||
}
|
|
||||||
dt {
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
.settings-column {
|
.settings-column {
|
||||||
padding: 10px 18px;
|
padding: 10px 18px;
|
||||||
}
|
}
|
||||||
@@ -942,6 +866,21 @@ dt {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
}
|
}
|
||||||
|
.selected-general-icon {
|
||||||
|
display: flex;
|
||||||
|
max-width: 260px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 8px auto;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #666;
|
||||||
|
background: rgb(23 42 82 / 70%);
|
||||||
|
}
|
||||||
|
.selected-general-icon img {
|
||||||
|
flex: 0 0 48px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
.general-icon-choice {
|
.general-icon-choice {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -949,16 +888,44 @@ dt {
|
|||||||
}
|
}
|
||||||
@media (max-width: 991px) {
|
@media (max-width: 991px) {
|
||||||
.legacy-page {
|
.legacy-page {
|
||||||
width: 500px;
|
width: 100%;
|
||||||
height: 1798.34px;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.my-page-mobile-scroll-spacer {
|
.my-page-mobile-scroll-spacer {
|
||||||
display: block;
|
display: none;
|
||||||
height: 100px;
|
|
||||||
}
|
}
|
||||||
.top-grid,
|
.top-grid,
|
||||||
.log-grid {
|
.log-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.title-row {
|
||||||
|
height: auto;
|
||||||
|
min-height: 54px;
|
||||||
|
}
|
||||||
|
dl > div {
|
||||||
|
grid-template-columns: 62px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
dt,
|
||||||
|
dd {
|
||||||
|
padding: 2px 3px;
|
||||||
|
}
|
||||||
|
.settings-column {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.screen-mode-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.button-group {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.item-group {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.custom-css textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,28 +1,202 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useMediaQuery } from '@vueuse/core';
|
|
||||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
import { formatOfficerLevelText } from '../utils/nationFormat';
|
||||||
import { resolveGeneralIconUrl } from '../utils/generalIcon';
|
import { resolveGeneralIconUrl } from '../utils/generalIcon';
|
||||||
|
import {
|
||||||
|
DISPLAY_SETTINGS_KEY,
|
||||||
|
cloneNationGeneralDisplaySetting,
|
||||||
|
compareGridValues,
|
||||||
|
defaultNationGeneralDisplaySettings,
|
||||||
|
lastUsedSettingsKey,
|
||||||
|
matchesKoreanSearch,
|
||||||
|
matchesNumberSearch,
|
||||||
|
parseStoredDisplaySettings,
|
||||||
|
parseStoredSettingKey,
|
||||||
|
serializeDisplaySettings,
|
||||||
|
type NationGeneralColumnId,
|
||||||
|
type NationGeneralColumnState,
|
||||||
|
type NationGeneralDisplaySetting,
|
||||||
|
type NationGeneralGroupId,
|
||||||
|
type NationGeneralSettingKey,
|
||||||
|
} from '../utils/nationGeneralGrid';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
|
type Result = Awaited<ReturnType<typeof trpc.nation.getGeneralList.query>>;
|
||||||
type General = Result['generals'][number];
|
type General = Result['generals'][number];
|
||||||
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;
|
type CellValue = string | number | null;
|
||||||
|
|
||||||
|
type ColumnDefinition = {
|
||||||
|
id: NationGeneralColumnId;
|
||||||
|
label: string;
|
||||||
|
width: number;
|
||||||
|
groupId?: NationGeneralGroupId;
|
||||||
|
summary?: boolean;
|
||||||
|
sortable?: boolean;
|
||||||
|
searchable?: 'text' | 'number';
|
||||||
|
};
|
||||||
|
|
||||||
|
type LayoutItem =
|
||||||
|
| { type: 'column'; columnId: NationGeneralColumnId }
|
||||||
|
| {
|
||||||
|
type: 'group';
|
||||||
|
groupId: NationGeneralGroupId;
|
||||||
|
label: string;
|
||||||
|
summaryId: NationGeneralColumnId;
|
||||||
|
children: NationGeneralColumnId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeaderSegment = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
colspan: number;
|
||||||
|
groupId?: NationGeneralGroupId;
|
||||||
|
open?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDefinition[] = [
|
||||||
|
{ id: 'icon', label: '아이콘', width: 80 },
|
||||||
|
{ id: 'name', label: '장수명', width: 126, sortable: true, searchable: 'text' },
|
||||||
|
{ id: 'officerLevel', label: '관직', width: 70, sortable: true, searchable: 'text' },
|
||||||
|
{ id: 'expDedLv_1', label: '', width: 60, groupId: 'expDedLv', summary: true },
|
||||||
|
{ id: 'dedlevel', label: '계급', width: 70, groupId: 'expDedLv', sortable: true, searchable: 'number' },
|
||||||
|
{ id: 'explevel', label: '명성', width: 60, groupId: 'expDedLv', sortable: true, searchable: 'number' },
|
||||||
|
{ id: 'stat_1', label: '통|무|지', width: 88, groupId: 'stat', summary: true },
|
||||||
|
{ id: 'leadership', label: '통솔', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
|
||||||
|
{ id: 'strength', label: '무력', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
|
||||||
|
{ id: 'intel', label: '지력', width: 60, groupId: 'stat', sortable: true, searchable: 'number' },
|
||||||
|
{ id: 'troop', label: '부대', width: 90, sortable: true, searchable: 'text' },
|
||||||
|
{ id: 'goldRice_1', label: '금/쌀', width: 80, groupId: 'goldRice', summary: true, sortable: true },
|
||||||
|
{ id: 'gold', label: '금', width: 70, groupId: 'goldRice', sortable: true, searchable: 'number' },
|
||||||
|
{ id: 'rice', label: '쌀', width: 70, groupId: 'goldRice', sortable: true, searchable: 'number' },
|
||||||
|
{ id: 'city', label: '도시', width: 60, sortable: true, searchable: 'text' },
|
||||||
|
{ id: 'crew', label: '병력', width: 70, sortable: true, searchable: 'number' },
|
||||||
|
{ id: 'specials_1', label: '요약', width: 80, groupId: 'specials', summary: true },
|
||||||
|
{ id: 'personal', label: '성격', width: 60, groupId: 'specials', sortable: true, searchable: 'text' },
|
||||||
|
{
|
||||||
|
id: 'specialDomestic',
|
||||||
|
label: '내특',
|
||||||
|
width: 60,
|
||||||
|
groupId: 'specials',
|
||||||
|
sortable: true,
|
||||||
|
searchable: 'text',
|
||||||
|
},
|
||||||
|
{ id: 'specialWar', label: '전특', width: 60, groupId: 'specials', sortable: true, searchable: 'text' },
|
||||||
|
{ id: 'years_1', label: '요약', width: 60, groupId: 'years', summary: true },
|
||||||
|
{ id: 'belong', label: '사관', width: 60, groupId: 'years', sortable: true, searchable: 'number' },
|
||||||
|
{ id: 'killturnAndRefresh_1', label: '벌점', width: 70, groupId: 'killturnAndRefresh', summary: true },
|
||||||
|
{
|
||||||
|
id: 'refreshScoreTotal',
|
||||||
|
label: '벌점',
|
||||||
|
width: 70,
|
||||||
|
groupId: 'killturnAndRefresh',
|
||||||
|
sortable: true,
|
||||||
|
searchable: 'number',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const layout: LayoutItem[] = [
|
||||||
|
{ type: 'column', columnId: 'icon' },
|
||||||
|
{ type: 'column', columnId: 'name' },
|
||||||
|
{ type: 'column', columnId: 'officerLevel' },
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
groupId: 'expDedLv',
|
||||||
|
label: '명성/계급',
|
||||||
|
summaryId: 'expDedLv_1',
|
||||||
|
children: ['dedlevel', 'explevel'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
groupId: 'stat',
|
||||||
|
label: '능력치',
|
||||||
|
summaryId: 'stat_1',
|
||||||
|
children: ['leadership', 'strength', 'intel'],
|
||||||
|
},
|
||||||
|
{ type: 'column', columnId: 'troop' },
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
groupId: 'goldRice',
|
||||||
|
label: '자금',
|
||||||
|
summaryId: 'goldRice_1',
|
||||||
|
children: ['gold', 'rice'],
|
||||||
|
},
|
||||||
|
{ type: 'column', columnId: 'city' },
|
||||||
|
{ type: 'column', columnId: 'crew' },
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
groupId: 'specials',
|
||||||
|
label: '특성',
|
||||||
|
summaryId: 'specials_1',
|
||||||
|
children: ['personal', 'specialDomestic', 'specialWar'],
|
||||||
|
},
|
||||||
|
{ type: 'group', groupId: 'years', label: '연도', summaryId: 'years_1', children: ['belong'] },
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
groupId: 'killturnAndRefresh',
|
||||||
|
label: '기타',
|
||||||
|
summaryId: 'killturnAndRefresh_1',
|
||||||
|
children: ['refreshScoreTotal'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const columnById = new Map(columns.map((column) => [column.id, column]));
|
||||||
const data = ref<Result | null>(null);
|
const data = ref<Result | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const sort = ref<Sort>(1);
|
|
||||||
const viewMenuOpen = ref(false);
|
const viewMenuOpen = ref(false);
|
||||||
const columnMenuOpen = ref(false);
|
const columnMenuOpen = ref(false);
|
||||||
const isNarrow = useMediaQuery('(max-width: 1000px)');
|
const currentSetting = ref<NationGeneralSettingKey>([true, 'normal']);
|
||||||
const compatButtonCount = computed(() => (isNarrow.value ? 52 : 55));
|
const displaySettings = ref(new Map<string, NationGeneralDisplaySetting>());
|
||||||
const compatInputCount = computed(() => (isNarrow.value ? 40 : 42));
|
const columnState = ref<NationGeneralColumnState[]>([]);
|
||||||
const renderedIconCount = computed(() => (isNarrow.value ? 15 : 16));
|
const groupState = ref<Record<NationGeneralGroupId, boolean>>({
|
||||||
const nameFilter = ref('');
|
expDedLv: true,
|
||||||
const officerFilter = ref('');
|
stat: true,
|
||||||
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
|
goldRice: true,
|
||||||
|
specials: false,
|
||||||
|
years: false,
|
||||||
|
killturnAndRefresh: true,
|
||||||
|
});
|
||||||
|
const filters = ref<Partial<Record<NationGeneralColumnId, string>>>({});
|
||||||
|
|
||||||
|
const applyDisplaySetting = (settingKey: NationGeneralSettingKey, setting: NationGeneralDisplaySetting) => {
|
||||||
|
const cloned = cloneNationGeneralDisplaySetting(setting);
|
||||||
|
columnState.value = cloned.column;
|
||||||
|
groupState.value = Object.fromEntries(cloned.columnGroup.map((group) => [group.groupId, group.open])) as Record<
|
||||||
|
NationGeneralGroupId,
|
||||||
|
boolean
|
||||||
|
>;
|
||||||
|
currentSetting.value = settingKey;
|
||||||
|
viewMenuOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDisplaySettings = () => {
|
||||||
|
displaySettings.value = parseStoredDisplaySettings(localStorage.getItem(DISPLAY_SETTINGS_KEY));
|
||||||
|
const lastUsed = parseStoredSettingKey(localStorage.getItem(lastUsedSettingsKey('pageNationGeneral')));
|
||||||
|
if (lastUsed?.[0]) {
|
||||||
|
applyDisplaySetting(lastUsed, defaultNationGeneralDisplaySettings[lastUsed[1]]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastUsed && !lastUsed[0]) {
|
||||||
|
const stored = displaySettings.value.get(lastUsed[1]);
|
||||||
|
if (stored) {
|
||||||
|
applyDisplaySetting(lastUsed, stored);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyDisplaySetting([true, 'normal'], defaultNationGeneralDisplaySettings.normal);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDisplaySettings();
|
||||||
|
|
||||||
|
watch(displaySettings, (settings) => localStorage.setItem(DISPLAY_SETTINGS_KEY, serializeDisplaySettings(settings)), {
|
||||||
|
deep: true,
|
||||||
|
});
|
||||||
|
watch(currentSetting, (setting) =>
|
||||||
|
localStorage.setItem(lastUsedSettingsKey('pageNationGeneral'), JSON.stringify(setting))
|
||||||
|
);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -34,36 +208,266 @@ const load = async () => {
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const generals = computed(() =>
|
|
||||||
[...(data.value?.generals ?? [])]
|
const stateById = computed(() => new Map(columnState.value.map((column) => [column.colId, column])));
|
||||||
.filter(
|
const isColumnVisible = (columnId: NationGeneralColumnId): boolean => !(stateById.value.get(columnId)?.hide ?? true);
|
||||||
(general) =>
|
|
||||||
general.name.includes(nameFilter.value.trim()) &&
|
const activeColumnIds = computed<NationGeneralColumnId[]>(() => {
|
||||||
formatOfficerLevelText(general.officerLevel, data.value?.nation.level).includes(
|
const active: NationGeneralColumnId[] = [];
|
||||||
officerFilter.value.trim()
|
for (const item of layout) {
|
||||||
)
|
if (item.type === 'column') {
|
||||||
)
|
if (isColumnVisible(item.columnId)) active.push(item.columnId);
|
||||||
.sort((a, b) => {
|
continue;
|
||||||
if (sort.value === 1) return a.npcState - b.npcState || b.officerLevel - a.officerLevel || a.id - b.id;
|
}
|
||||||
if (sort.value === 2) return b.dedicationLevel - a.dedicationLevel || a.id - b.id;
|
if (groupState.value[item.groupId]) {
|
||||||
if (sort.value === 3) return b.experienceLevel - a.experienceLevel || a.id - b.id;
|
active.push(...item.children.filter(isColumnVisible));
|
||||||
if (sort.value === 4) return b.stats.leadership - a.stats.leadership || a.id - b.id;
|
} else if (isColumnVisible(item.summaryId)) {
|
||||||
if (sort.value === 5) return b.stats.strength - a.stats.strength || a.id - b.id;
|
active.push(item.summaryId);
|
||||||
if (sort.value === 6) return b.stats.intelligence - a.stats.intelligence || a.id - b.id;
|
}
|
||||||
if (sort.value === 7) return b.gold - a.gold || a.id - b.id;
|
}
|
||||||
if (sort.value === 8) return b.rice - a.rice || a.id - b.id;
|
return active;
|
||||||
if (sort.value === 9) return (visibleCrew(b) ?? -1) - (visibleCrew(a) ?? -1) || a.id - b.id;
|
});
|
||||||
if (sort.value === 10) return b.refreshScoreTotal - a.refreshScoreTotal || a.id - b.id;
|
|
||||||
if (sort.value === 11) return (a.personality?.name ?? '').localeCompare(b.personality?.name ?? '');
|
const activeColumns = computed(() =>
|
||||||
if (sort.value === 12) return (a.specialDomestic?.name ?? '').localeCompare(b.specialDomestic?.name ?? '');
|
activeColumnIds.value.map((columnId) => columnById.get(columnId)).filter((column) => column !== undefined)
|
||||||
if (sort.value === 13) return (a.specialWar?.name ?? '').localeCompare(b.specialWar?.name ?? '');
|
|
||||||
if (sort.value === 14) return b.belong - a.belong || a.id - b.id;
|
|
||||||
if (sort.value === 15) return b.npcState - a.npcState || a.id - b.id;
|
|
||||||
return a.id - b.id;
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
|
|
||||||
|
const tableWidth = computed(() =>
|
||||||
|
Math.max(
|
||||||
|
1000,
|
||||||
|
activeColumns.value.reduce((sum, column) => sum + column.width, 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const headerSegments = computed<HeaderSegment[]>(() => {
|
||||||
|
const segments: HeaderSegment[] = [];
|
||||||
|
for (const item of layout) {
|
||||||
|
if (item.type === 'column') {
|
||||||
|
if (activeColumnIds.value.includes(item.columnId)) {
|
||||||
|
segments.push({ key: item.columnId, label: '', colspan: 1 });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const visibleIds = groupState.value[item.groupId]
|
||||||
|
? item.children.filter((columnId) => activeColumnIds.value.includes(columnId))
|
||||||
|
: activeColumnIds.value.includes(item.summaryId)
|
||||||
|
? [item.summaryId]
|
||||||
|
: [];
|
||||||
|
if (visibleIds.length) {
|
||||||
|
segments.push({
|
||||||
|
key: item.groupId,
|
||||||
|
label: item.label,
|
||||||
|
colspan: visibleIds.length,
|
||||||
|
groupId: item.groupId,
|
||||||
|
open: groupState.value[item.groupId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleCrew = (general: General): number | null => ('crew' in general ? general.crew : null);
|
||||||
|
const officerText = (general: General): string => {
|
||||||
|
const title = formatOfficerLevelText(general.officerLevel, data.value?.nation.level);
|
||||||
|
return general.officerCityName && general.officerLevel >= 2 && general.officerLevel <= 4
|
||||||
|
? `${general.officerCityName}\n${title}`
|
||||||
|
: title;
|
||||||
|
};
|
||||||
|
const protectedText = (value: string | null): string => value ?? (data.value?.viewer.permission ? '-' : '?');
|
||||||
|
|
||||||
|
const cellValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
|
||||||
|
switch (columnId) {
|
||||||
|
case 'name':
|
||||||
|
return general.name;
|
||||||
|
case 'officerLevel':
|
||||||
|
return officerText(general);
|
||||||
|
case 'expDedLv_1':
|
||||||
|
return `Lv ${general.experienceLevel}\n${general.dedicationText}`;
|
||||||
|
case 'dedlevel':
|
||||||
|
return `${general.dedicationText}\n(${general.bill.toLocaleString()})`;
|
||||||
|
case 'explevel':
|
||||||
|
return `Lv ${general.experienceLevel}\n(${general.personality?.name ?? '-'})`;
|
||||||
|
case 'stat_1':
|
||||||
|
return `${general.stats.leadership}|${general.stats.strength}|${general.stats.intelligence}`;
|
||||||
|
case 'leadership':
|
||||||
|
return general.stats.leadership;
|
||||||
|
case 'strength':
|
||||||
|
return general.stats.strength;
|
||||||
|
case 'intel':
|
||||||
|
return general.stats.intelligence;
|
||||||
|
case 'troop':
|
||||||
|
return protectedText(general.troopName);
|
||||||
|
case 'goldRice_1':
|
||||||
|
return `${general.gold.toLocaleString()} 금\n${general.rice.toLocaleString()} 쌀`;
|
||||||
|
case 'gold':
|
||||||
|
return general.gold;
|
||||||
|
case 'rice':
|
||||||
|
return general.rice;
|
||||||
|
case 'city':
|
||||||
|
return protectedText(general.cityName);
|
||||||
|
case 'crew':
|
||||||
|
return visibleCrew(general);
|
||||||
|
case 'specials_1':
|
||||||
|
return `${general.personality?.name ?? '-'}\n${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
|
||||||
|
case 'personal':
|
||||||
|
return general.personality?.name ?? '-';
|
||||||
|
case 'specialDomestic':
|
||||||
|
return general.specialDomestic?.name ?? '-';
|
||||||
|
case 'specialWar':
|
||||||
|
return general.specialWar?.name ?? '-';
|
||||||
|
case 'years_1':
|
||||||
|
return `${general.belong}년`;
|
||||||
|
case 'belong':
|
||||||
|
return general.belong;
|
||||||
|
case 'killturnAndRefresh_1':
|
||||||
|
case 'refreshScoreTotal':
|
||||||
|
return Number(general.refreshScoreTotal);
|
||||||
|
case 'icon':
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
|
||||||
|
switch (columnId) {
|
||||||
|
case 'officerLevel':
|
||||||
|
return officerText(general);
|
||||||
|
case 'dedlevel':
|
||||||
|
return general.dedicationLevel;
|
||||||
|
case 'explevel':
|
||||||
|
return general.experienceLevel;
|
||||||
|
default:
|
||||||
|
return cellValue(general, columnId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortValue = (general: General, columnId: NationGeneralColumnId): CellValue => {
|
||||||
|
switch (columnId) {
|
||||||
|
case 'name':
|
||||||
|
return `${String(general.npcState).padStart(3, '0')}:${general.name}`;
|
||||||
|
case 'officerLevel':
|
||||||
|
return general.officerLevel;
|
||||||
|
case 'dedlevel':
|
||||||
|
return general.dedicationLevel;
|
||||||
|
case 'explevel':
|
||||||
|
return general.experienceLevel;
|
||||||
|
case 'goldRice_1':
|
||||||
|
return general.gold + general.rice;
|
||||||
|
default:
|
||||||
|
return cellValue(general, columnId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generals = computed(() => {
|
||||||
|
const filtered = [...(data.value?.generals ?? [])].filter((general) =>
|
||||||
|
Object.entries(filters.value).every(([rawColumnId, query]) => {
|
||||||
|
if (!query) return true;
|
||||||
|
const columnId = rawColumnId as NationGeneralColumnId;
|
||||||
|
const column = columnById.get(columnId);
|
||||||
|
const value = filterValue(general, columnId);
|
||||||
|
if (column?.searchable === 'number')
|
||||||
|
return matchesNumberSearch(typeof value === 'number' ? value : null, query);
|
||||||
|
return matchesKoreanSearch(value === null ? '' : String(value), query);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const sorts = columnState.value
|
||||||
|
.filter((column): column is NationGeneralColumnState & { sort: 'asc' | 'desc' } => column.sort !== null)
|
||||||
|
.sort((left, right) => (left.sortIndex ?? 0) - (right.sortIndex ?? 0));
|
||||||
|
return filtered.sort((left, right) => {
|
||||||
|
for (const sort of sorts) {
|
||||||
|
const compared = compareGridValues(sortValue(left, sort.colId), sortValue(right, sort.colId));
|
||||||
|
if (compared) return sort.sort === 'asc' ? compared : -compared;
|
||||||
|
}
|
||||||
|
return left.id - right.id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const setDisplayMode = (mode: 'normal' | 'war') =>
|
||||||
|
applyDisplaySetting([true, mode], defaultNationGeneralDisplaySettings[mode]);
|
||||||
|
|
||||||
|
const currentDisplaySetting = (): NationGeneralDisplaySetting => ({
|
||||||
|
column: columnState.value.map((column) => ({ ...column })),
|
||||||
|
columnGroup: Object.entries(groupState.value).map(([groupId, open]) => ({
|
||||||
|
groupId: groupId as NationGeneralGroupId,
|
||||||
|
open,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeDisplaySetting = () => {
|
||||||
|
const defaultName = currentSetting.value[0] ? '' : currentSetting.value[1];
|
||||||
|
const nickname = window.prompt('선택한 설정의 별명을 지어주세요', defaultName)?.trim();
|
||||||
|
if (!nickname) return;
|
||||||
|
if (displaySettings.value.has(nickname) && !window.confirm('이미 있는 이름입니다. 덮어쓸까요?')) return;
|
||||||
|
const next = new Map(displaySettings.value);
|
||||||
|
const setting = currentDisplaySetting();
|
||||||
|
next.set(nickname, setting);
|
||||||
|
displaySettings.value = next;
|
||||||
|
currentSetting.value = [false, nickname];
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteDisplaySetting = (key: string) => {
|
||||||
|
if (!window.confirm(`${key} 설정을 지울까요?`)) return;
|
||||||
|
const next = new Map(displaySettings.value);
|
||||||
|
next.delete(key);
|
||||||
|
displaySettings.value = next;
|
||||||
|
if (!currentSetting.value[0] && currentSetting.value[1] === key) setDisplayMode('normal');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGroup = (groupId: NationGeneralGroupId) => {
|
||||||
|
groupState.value = { ...groupState.value, [groupId]: !groupState.value[groupId] };
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleColumn = (columnId: NationGeneralColumnId) => {
|
||||||
|
columnState.value = columnState.value.map((column) =>
|
||||||
|
column.colId === columnId ? { ...column, hide: !column.hide } : column
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextSort = (columnId: NationGeneralColumnId, current: 'asc' | 'desc' | null): 'asc' | 'desc' | null => {
|
||||||
|
const order: ('asc' | 'desc' | null)[] = columnId === 'name' ? ['asc', 'desc', null] : ['desc', 'asc', null];
|
||||||
|
const index = order.indexOf(current);
|
||||||
|
return order[(index + 1) % order.length] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortColumn = (columnId: NationGeneralColumnId, event: MouseEvent) => {
|
||||||
|
const definition = columnById.get(columnId);
|
||||||
|
if (!definition?.sortable) return;
|
||||||
|
const current = stateById.value.get(columnId)?.sort ?? null;
|
||||||
|
const next = nextSort(columnId, current);
|
||||||
|
const existingSortIndex = stateById.value.get(columnId)?.sortIndex;
|
||||||
|
const maxSortIndex = Math.max(-1, ...columnState.value.map((column) => column.sortIndex ?? -1));
|
||||||
|
columnState.value = columnState.value.map((column) => {
|
||||||
|
if (column.colId === columnId) {
|
||||||
|
const { sortIndex: _sortIndex, ...withoutSortIndex } = column;
|
||||||
|
return next
|
||||||
|
? { ...withoutSortIndex, sort: next, sortIndex: existingSortIndex ?? maxSortIndex + 1 }
|
||||||
|
: { ...withoutSortIndex, sort: null };
|
||||||
|
}
|
||||||
|
if (event.shiftKey) return column;
|
||||||
|
const { sortIndex: _sortIndex, ...withoutSortIndex } = column;
|
||||||
|
return { ...withoutSortIndex, sort: null };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortIndicator = (columnId: NationGeneralColumnId): string => {
|
||||||
|
const column = stateById.value.get(columnId);
|
||||||
|
if (!column?.sort) return '';
|
||||||
|
const order = column.sortIndex === undefined ? '' : `${column.sortIndex + 1}`;
|
||||||
|
return `${column.sort === 'asc' ? '▲' : '▼'}${order}`;
|
||||||
|
};
|
||||||
|
|
||||||
const iconUrl = (general: General) => resolveGeneralIconUrl(general);
|
const iconUrl = (general: General) => resolveGeneralIconUrl(general);
|
||||||
|
const cellTitle = (general: General, columnId: NationGeneralColumnId): string => {
|
||||||
|
if (columnId === 'personal') return general.personality?.info ?? '';
|
||||||
|
if (columnId === 'specialDomestic') return general.specialDomestic?.info ?? '';
|
||||||
|
if (columnId === 'specialWar') return general.specialWar?.info ?? '';
|
||||||
|
if (columnId === 'specials_1') {
|
||||||
|
return [general.personality?.info, general.specialDomestic?.info, general.specialWar?.info]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -80,40 +484,68 @@ onMounted(load);
|
|||||||
<button
|
<button
|
||||||
class="top-button mode-button"
|
class="top-button mode-button"
|
||||||
:aria-expanded="viewMenuOpen"
|
:aria-expanded="viewMenuOpen"
|
||||||
@click="viewMenuOpen = !viewMenuOpen"
|
@click="
|
||||||
|
viewMenuOpen = !viewMenuOpen;
|
||||||
|
columnMenuOpen = false;
|
||||||
|
"
|
||||||
>
|
>
|
||||||
보기 모드⌄
|
보기 모드⌄
|
||||||
</button>
|
</button>
|
||||||
<span v-if="viewMenuOpen" class="dropdown-menu">
|
<span v-if="viewMenuOpen" class="dropdown-menu view-mode-list">
|
||||||
<button
|
<button @click="setDisplayMode('normal')">기본</button>
|
||||||
@click="
|
<button @click="setDisplayMode('war')">전투</button>
|
||||||
sort = 1;
|
<span class="menu-divider"></span>
|
||||||
viewMenuOpen = false;
|
<button @click="storeDisplaySetting">🔖 보관하기</button>
|
||||||
"
|
<template v-if="displaySettings.size">
|
||||||
>
|
<span class="menu-divider"></span>
|
||||||
기본
|
<span v-for="[key, setting] in displaySettings" :key="key" class="saved-setting">
|
||||||
</button>
|
<button class="saved-setting-name" @click="applyDisplaySetting([false, key], setting)">
|
||||||
<button
|
{{ key }}
|
||||||
@click="
|
</button>
|
||||||
sort = 4;
|
<button
|
||||||
viewMenuOpen = false;
|
class="saved-setting-delete"
|
||||||
"
|
:aria-label="`${key} 설정 삭제`"
|
||||||
>
|
@click.stop="deleteDisplaySetting(key)"
|
||||||
전투
|
>
|
||||||
</button>
|
삭제
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
<button class="top-button columns-button" @click="columnMenuOpen = !columnMenuOpen">
|
<button
|
||||||
|
class="top-button columns-button"
|
||||||
|
:aria-expanded="columnMenuOpen"
|
||||||
|
@click="
|
||||||
|
columnMenuOpen = !columnMenuOpen;
|
||||||
|
viewMenuOpen = false;
|
||||||
|
"
|
||||||
|
>
|
||||||
열 선택⌄
|
열 선택⌄
|
||||||
</button>
|
</button>
|
||||||
<span v-if="columnMenuOpen" class="dropdown-menu column-menu">
|
<span v-if="columnMenuOpen" class="dropdown-menu column-menu">
|
||||||
<label
|
<template v-for="item in layout" :key="item.type === 'column' ? item.columnId : item.groupId">
|
||||||
v-for="label in ['아이콘', '장수명', '관직', '명성/계급', '능력치', '자금', '특성']"
|
<label v-if="item.type === 'column' && item.columnId !== 'name'">
|
||||||
:key="label"
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
<input type="checkbox" checked /> {{ label }}
|
:checked="isColumnVisible(item.columnId)"
|
||||||
</label>
|
@change="toggleColumn(item.columnId)"
|
||||||
|
/>
|
||||||
|
{{ columnById.get(item.columnId)?.label }}
|
||||||
|
</label>
|
||||||
|
<template v-else-if="item.type === 'group'">
|
||||||
|
<span class="column-group-label">{{ item.label }}</span>
|
||||||
|
<label v-for="columnId in item.children" :key="columnId" class="child-column">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isColumnVisible(columnId)"
|
||||||
|
@change="toggleColumn(columnId)"
|
||||||
|
/>
|
||||||
|
{{ columnById.get(columnId)?.label }}
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -121,100 +553,99 @@ onMounted(load);
|
|||||||
<p v-if="error" class="state error" role="alert">{{ error }}</p>
|
<p v-if="error" class="state error" role="alert">{{ error }}</p>
|
||||||
<p v-else-if="loading" class="state">불러오는 중...</p>
|
<p v-else-if="loading" class="state">불러오는 중...</p>
|
||||||
<div v-else class="grid-shell">
|
<div v-else class="grid-shell">
|
||||||
<table id="nation-general-list">
|
<table id="nation-general-list" :style="{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col
|
<col v-for="column in activeColumns" :key="column.id" :style="{ width: `${column.width}px` }" />
|
||||||
v-for="(width, index) in [80, 126, 70, 70, 60, 60, 60, 60, 70, 70, 80, 100, 94]"
|
|
||||||
:key="index"
|
|
||||||
:style="{ width: `${width}px` }"
|
|
||||||
/>
|
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="group-head">
|
<tr class="group-head">
|
||||||
<th colspan="2"></th>
|
<th v-for="segment in headerSegments" :key="segment.key" :colspan="segment.colspan">
|
||||||
<th></th>
|
<button
|
||||||
<th>명성/계급 ‹</th>
|
v-if="segment.groupId"
|
||||||
<th colspan="3">능력치 ‹</th>
|
class="group-toggle"
|
||||||
<th colspan="2">자금 ‹</th>
|
:aria-expanded="segment.open"
|
||||||
<th colspan="2">특성 ›</th>
|
:aria-label="`${segment.label} ${segment.open ? '접기' : '펼치기'}`"
|
||||||
<th>연도 ›</th>
|
@click="toggleGroup(segment.groupId)"
|
||||||
<th>기타 ‹</th>
|
>
|
||||||
|
{{ segment.label }} {{ segment.open ? '‹' : '›' }}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>아이콘</th>
|
<th v-for="column in activeColumns" :key="column.id">
|
||||||
<th>장수명</th>
|
<button
|
||||||
<th>관직</th>
|
class="sort-button"
|
||||||
<th>계급</th>
|
:class="{ sortable: column.sortable }"
|
||||||
<th>명성</th>
|
:disabled="!column.sortable"
|
||||||
<th>통솔</th>
|
:aria-label="column.sortable ? `${column.label} 정렬` : undefined"
|
||||||
<th>무력</th>
|
@click="sortColumn(column.id, $event)"
|
||||||
<th>지력</th>
|
>
|
||||||
<th>금</th>
|
{{ column.label }}
|
||||||
<th v-if="!isNarrow">쌀</th>
|
<span class="sort-indicator">{{ sortIndicator(column.id) }}</span>
|
||||||
<th v-if="!isNarrow">요약</th>
|
</button>
|
||||||
<th v-if="!isNarrow">요약</th>
|
</th>
|
||||||
<th v-if="!isNarrow">벌점 ↓</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="filter-head">
|
<tr class="filter-head">
|
||||||
<th></th>
|
<th v-for="column in activeColumns" :key="column.id">
|
||||||
<th><input v-model="nameFilter" aria-label="장수명 필터" /><span>▽</span></th>
|
<template v-if="column.searchable">
|
||||||
<th><input v-model="officerFilter" aria-label="관직 필터" /><span>▽</span></th>
|
<input
|
||||||
<th><input aria-label="계급 필터" /><span>▽</span></th>
|
v-model="filters[column.id]"
|
||||||
<th><input aria-label="명성 필터" /><span>▽</span></th>
|
type="search"
|
||||||
<th><input aria-label="통솔 필터" /><span>▽</span></th>
|
:inputmode="column.searchable === 'number' ? 'decimal' : 'search'"
|
||||||
<th><input aria-label="무력 필터" /><span>▽</span></th>
|
:aria-label="`${column.label} 필터`"
|
||||||
<th><input aria-label="지력 필터" /><span>▽</span></th>
|
:placeholder="column.searchable === 'number' ? '=, >, <' : ''"
|
||||||
<th><input aria-label="금 필터" /><span>▽</span></th>
|
/>
|
||||||
<th v-if="!isNarrow"><input aria-label="쌀 필터" /><span>▽</span></th>
|
<span>▽</span>
|
||||||
<th v-if="!isNarrow"></th>
|
</template>
|
||||||
<th v-if="!isNarrow"></th>
|
</th>
|
||||||
<th v-if="!isNarrow"><input aria-label="벌점 필터" /><span>▽</span></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(general, index) in generals" :key="general.id">
|
<tr v-for="(general, index) in generals" :key="general.id" :data-general-id="general.id">
|
||||||
<td class="icon-cell">
|
|
||||||
<img v-if="index < renderedIconCount" :src="iconUrl(general)" alt="" />
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="icon-background"
|
|
||||||
:style="{ backgroundImage: `url(${iconUrl(general)})` }"
|
|
||||||
></span>
|
|
||||||
</td>
|
|
||||||
<td :class="`name-cell npc-${general.npcState}`">{{ general.name }}</td>
|
|
||||||
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
|
|
||||||
<td>{{ general.dedicationText }}<br />({{ general.bill.toLocaleString() }})</td>
|
|
||||||
<td>Lv {{ general.experienceLevel }}<br />({{ general.personality?.name ?? '-' }})</td>
|
|
||||||
<td>{{ general.stats.leadership }}</td>
|
|
||||||
<td>{{ general.stats.strength }}</td>
|
|
||||||
<td>{{ general.stats.intelligence }}</td>
|
|
||||||
<td>{{ general.gold.toLocaleString() }} 금</td>
|
|
||||||
<td v-if="!isNarrow">{{ general.rice.toLocaleString() }} 쌀</td>
|
|
||||||
<td v-if="!isNarrow" :title="general.personality?.info ?? ''">
|
|
||||||
{{ general.personality?.name ?? '-' }}<br />{{ general.specialDomestic?.name ?? '-' }}
|
|
||||||
</td>
|
|
||||||
<td
|
<td
|
||||||
v-if="!isNarrow"
|
v-for="column in activeColumns"
|
||||||
:title="
|
:key="column.id"
|
||||||
[general.specialDomestic?.info, general.specialWar?.info].filter(Boolean).join('\n')
|
:class="{
|
||||||
"
|
'icon-cell': column.id === 'icon',
|
||||||
|
'name-cell': column.id === 'name',
|
||||||
|
[`npc-${general.npcState}`]: column.id === 'name',
|
||||||
|
'numeric-cell':
|
||||||
|
column.searchable === 'number' ||
|
||||||
|
['goldRice_1', 'killturnAndRefresh_1'].includes(column.id),
|
||||||
|
}"
|
||||||
|
:title="cellTitle(general, column.id)"
|
||||||
>
|
>
|
||||||
{{ special(general) }}
|
<template v-if="column.id === 'icon'">
|
||||||
</td>
|
<img v-if="index < 16" :src="iconUrl(general)" alt="" />
|
||||||
<td v-if="!isNarrow">
|
<span
|
||||||
{{ general.refreshScoreTotal }}점<br />({{ general.belong ? '자주' : '안함' }})
|
v-else
|
||||||
|
class="icon-background"
|
||||||
|
:style="{ backgroundImage: `url(${iconUrl(general)})` }"
|
||||||
|
></span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.id === 'gold'">{{ general.gold.toLocaleString() }} 금</template>
|
||||||
|
<template v-else-if="column.id === 'rice'">{{ general.rice.toLocaleString() }} 쌀</template>
|
||||||
|
<template v-else-if="column.id === 'crew'">
|
||||||
|
{{ visibleCrew(general)?.toLocaleString() ?? '?'
|
||||||
|
}}<span v-if="visibleCrew(general) !== null">명</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.id === 'belong'">{{ general.belong }}년</template>
|
||||||
|
<template
|
||||||
|
v-else-if="column.id === 'refreshScoreTotal' || column.id === 'killturnAndRefresh_1'"
|
||||||
|
>
|
||||||
|
{{ general.refreshScoreTotal.toLocaleString() }}점
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ cellValue(general, column.id) }}</template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr v-if="!generals.length" class="empty-row">
|
||||||
|
<td :colspan="activeColumns.length">검색 결과가 없습니다.</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="ag-compat-controls" aria-hidden="true">
|
<div class="ag-compat-controls" aria-hidden="true">
|
||||||
<button
|
<button v-for="index in 55" :key="`button-${index}`" type="button" tabindex="-1"></button>
|
||||||
v-for="index in compatButtonCount"
|
<input v-for="index in 42" :key="`input-${index}`" tabindex="-1" />
|
||||||
:key="`button-${index}`"
|
|
||||||
type="button"
|
|
||||||
tabindex="-1"
|
|
||||||
></button>
|
|
||||||
<input v-for="index in compatInputCount" :key="`input-${index}`" tabindex="-1" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -222,9 +653,8 @@ onMounted(load);
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.general-page {
|
.general-page {
|
||||||
width: 100%;
|
width: 1000px;
|
||||||
min-width: 500px;
|
min-width: 1000px;
|
||||||
max-width: 1000px;
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
font: 14px/21px var(--sammo-font-sans);
|
font: 14px/21px var(--sammo-font-sans);
|
||||||
@@ -242,7 +672,6 @@ onMounted(load);
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
background-image: var(--sammo-texture-walnut);
|
background-image: var(--sammo-texture-walnut);
|
||||||
/* Ref's `.back_bar` has no bottom rule; the grid below draws its own. */
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.top-bar strong {
|
.top-bar strong {
|
||||||
@@ -262,20 +691,19 @@ onMounted(load);
|
|||||||
.right-actions {
|
.right-actions {
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
/* Ref Lumen primary: the bottom edge carries the pressed-state movement. */
|
|
||||||
.top-button {
|
.top-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
width: 89px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-right: 1px solid #151515;
|
border-right: 1px solid #151515;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
width: 89px;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -289,14 +717,11 @@ onMounted(load);
|
|||||||
background: #375a7f;
|
background: #375a7f;
|
||||||
border-bottom: 0 solid #325172;
|
border-bottom: 0 solid #325172;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ref Lumen primary: a 3px bottom edge appears on hover and stays while open. */
|
|
||||||
.mode-button:hover,
|
.mode-button:hover,
|
||||||
.mode-button[aria-expanded='true'],
|
.mode-button[aria-expanded='true'],
|
||||||
.mode-button:active {
|
.mode-button:active {
|
||||||
border-bottom-width: 3px;
|
border-bottom-width: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-button,
|
.mode-button,
|
||||||
.columns-button {
|
.columns-button {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
@@ -312,16 +737,22 @@ onMounted(load);
|
|||||||
}
|
}
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5;
|
z-index: 20;
|
||||||
top: 32px;
|
top: 32px;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 150px;
|
width: 170px;
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
background: #252a2c;
|
overflow-y: auto;
|
||||||
border: 1px solid #596164;
|
border: 1px solid #596164;
|
||||||
|
background: #252a2c;
|
||||||
|
}
|
||||||
|
.view-mode-list {
|
||||||
|
width: 180px;
|
||||||
}
|
}
|
||||||
.dropdown-menu button,
|
.dropdown-menu button,
|
||||||
.dropdown-menu label {
|
.dropdown-menu label,
|
||||||
|
.column-group-label {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
@@ -330,6 +761,30 @@ onMounted(load);
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
.dropdown-menu button:not(.saved-setting-delete):hover,
|
||||||
|
.dropdown-menu label:hover {
|
||||||
|
background: #3a4144;
|
||||||
|
}
|
||||||
|
.menu-divider {
|
||||||
|
display: block;
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 0;
|
||||||
|
background: #596164;
|
||||||
|
}
|
||||||
|
.saved-setting {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 48px;
|
||||||
|
}
|
||||||
|
.saved-setting-delete {
|
||||||
|
padding: 2px !important;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
.column-group-label {
|
||||||
|
color: #9ca6aa;
|
||||||
|
}
|
||||||
|
.child-column {
|
||||||
|
padding-left: 17px !important;
|
||||||
|
}
|
||||||
.grid-shell {
|
.grid-shell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(100vh - 32px);
|
height: calc(100vh - 32px);
|
||||||
@@ -340,23 +795,21 @@ onMounted(load);
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
table {
|
table {
|
||||||
width: 1000px;
|
border-collapse: separate;
|
||||||
min-width: 1000px;
|
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
background: #293033;
|
background: #293033;
|
||||||
|
color: #f5f5f5;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
color: #f5f5f5;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
|
padding: 0 4px;
|
||||||
|
overflow: hidden;
|
||||||
border-right: 1px solid #40484b;
|
border-right: 1px solid #40484b;
|
||||||
border-bottom: 1px solid #4a5255;
|
border-bottom: 1px solid #4a5255;
|
||||||
padding: 0 4px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
th {
|
th {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -369,6 +822,36 @@ th {
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
border-bottom-color: #303537;
|
border-bottom-color: #303537;
|
||||||
}
|
}
|
||||||
|
.group-toggle,
|
||||||
|
.sort-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.group-toggle,
|
||||||
|
.sort-button.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.group-toggle:hover,
|
||||||
|
.sort-button.sortable:hover,
|
||||||
|
.group-toggle:focus-visible,
|
||||||
|
.sort-button.sortable:focus-visible {
|
||||||
|
color: #fff;
|
||||||
|
background: #303638;
|
||||||
|
outline: 1px solid #8aa4b2;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.sort-button:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.sort-indicator {
|
||||||
|
color: #8dd4ff;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
.filter-head th {
|
.filter-head th {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 3px 4px;
|
padding: 3px 4px;
|
||||||
@@ -380,6 +863,14 @@ th {
|
|||||||
background: #252a2c;
|
background: #252a2c;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
.filter-head input:focus-visible {
|
||||||
|
border-color: #8dd4ff;
|
||||||
|
outline: 1px solid #8dd4ff;
|
||||||
|
}
|
||||||
|
.filter-head input::placeholder {
|
||||||
|
color: #8f999d;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
.filter-head span {
|
.filter-head span {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
color: #a5b5bf;
|
color: #a5b5bf;
|
||||||
@@ -392,7 +883,7 @@ tbody tr:hover {
|
|||||||
background: #343c3f;
|
background: #343c3f;
|
||||||
}
|
}
|
||||||
td {
|
td {
|
||||||
white-space: nowrap;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
.icon-cell {
|
.icon-cell {
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
@@ -416,21 +907,16 @@ td {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.name-cell {
|
.name-cell {
|
||||||
text-align: left;
|
|
||||||
color: skyblue;
|
color: skyblue;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
th:nth-child(9),
|
.numeric-cell {
|
||||||
td:nth-child(9),
|
|
||||||
th:nth-child(10),
|
|
||||||
td:nth-child(10) {
|
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.state {
|
.state {
|
||||||
margin: 40px;
|
margin: 40px;
|
||||||
}
|
}
|
||||||
.npc-0 {
|
.npc-0,
|
||||||
color: skyblue;
|
|
||||||
}
|
|
||||||
.npc-1 {
|
.npc-1 {
|
||||||
color: skyblue;
|
color: skyblue;
|
||||||
}
|
}
|
||||||
@@ -443,6 +929,10 @@ td:nth-child(10) {
|
|||||||
.error {
|
.error {
|
||||||
color: #ff7373;
|
color: #ff7373;
|
||||||
}
|
}
|
||||||
|
.empty-row td {
|
||||||
|
height: 68px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@media (max-width: 1000px) {
|
@media (max-width: 1000px) {
|
||||||
.general-page {
|
.general-page {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
|
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
|
||||||
@@ -147,7 +148,7 @@ onMounted(load);
|
|||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ general.killTurn }}</td>
|
<td>{{ general.killTurn }}</td>
|
||||||
<td>{{ general.turnTime.slice(14, 19) }}</td>
|
<td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -159,8 +160,8 @@ onMounted(load);
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="legacy-banner">
|
<td class="legacy-banner">
|
||||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
|
||||||
HideD(hided62@gmail.com) /
|
/
|
||||||
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
|
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
@@ -67,16 +68,9 @@ const newVoteOptions = computed(() => newVoteOptionsText.value.split('\n').filte
|
|||||||
|
|
||||||
const percentage = (count: number, total: number): string => ((count / Math.max(1, total)) * 100).toFixed(1);
|
const percentage = (count: number, total: number): string => ((count / Math.max(1, total)) * 100).toFixed(1);
|
||||||
|
|
||||||
const formatStartDate = (value: string): string => value.slice(0, 10);
|
const formatStartDate = (value: string): string => formatServerDateTime(value, { format: 'date' });
|
||||||
|
|
||||||
const formatCommentDate = (value: string): string => {
|
const formatCommentDate = (value: string): string => formatServerDateTime(value, { format: 'monthDayTime' });
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
const pad = (part: number) => String(part).padStart(2, '0');
|
|
||||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const voteColor = (index: number): string =>
|
const voteColor = (index: number): string =>
|
||||||
['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!;
|
['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||||
|
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
import { resolveTournamentStageName } from '../utils/tournamentStatus';
|
||||||
|
|
||||||
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||||
|
|
||||||
@@ -12,22 +15,11 @@ const loading = ref(false);
|
|||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const actionMessage = ref<string | null>(null);
|
const actionMessage = ref<string | null>(null);
|
||||||
const adminEnabled = ref(false);
|
const adminEnabled = ref(false);
|
||||||
|
const activeFinalGroup = ref(0);
|
||||||
|
const activePreliminaryGroup = ref(0);
|
||||||
|
|
||||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||||
const stageNames = [
|
const typeStatNames = ['종합', '통솔', '무력', '지력'];
|
||||||
'경기 없음',
|
|
||||||
'참가 모집중',
|
|
||||||
'예선 진행중',
|
|
||||||
'본선 추첨중',
|
|
||||||
'본선 진행중',
|
|
||||||
'16강 배정중',
|
|
||||||
'베팅 진행중',
|
|
||||||
'16강 진행중',
|
|
||||||
'8강 진행중',
|
|
||||||
'4강 진행중',
|
|
||||||
'결승 진행중',
|
|
||||||
];
|
|
||||||
|
|
||||||
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
|
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -62,7 +54,9 @@ const matchesAt = (stage: number) =>
|
|||||||
.sort((a, b) => a.roundIndex - b.roundIndex);
|
.sort((a, b) => a.roundIndex - b.roundIndex);
|
||||||
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
|
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
|
||||||
const totalBet = computed(() => betting.value?.totalAmount ?? 0);
|
const totalBet = computed(() => betting.value?.totalAmount ?? 0);
|
||||||
const openingTime = computed(() => snapshot.value?.state?.nextAt?.slice(11, 16) ?? '--:--');
|
const openingTime = computed(() =>
|
||||||
|
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
||||||
|
);
|
||||||
const betTotals = computed(() => betting.value?.totals as Record<number, number> | undefined);
|
const betTotals = computed(() => betting.value?.totals as Record<number, number> | undefined);
|
||||||
const isParticipant = computed(() =>
|
const isParticipant = computed(() =>
|
||||||
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
|
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
|
||||||
@@ -74,6 +68,26 @@ const groups = computed(() =>
|
|||||||
.sort((a, b) => (a.finalRank ?? 99) - (b.finalRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
|
.sort((a, b) => (a.finalRank ?? 99) - (b.finalRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const preliminaryGroups = computed(() =>
|
||||||
|
Array.from({ length: 8 }, (_, index) =>
|
||||||
|
(snapshot.value?.participants ?? [])
|
||||||
|
.filter((participant) => participant.groupId === index)
|
||||||
|
.sort((a, b) => (a.seedRank ?? 99) - (b.seedRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const groupNames = ['一', '二', '三', '四', '五', '六', '七', '八'];
|
||||||
|
const statOf = (participant: Snapshot['participants'][number] | undefined): number | '' => {
|
||||||
|
if (!participant) return '';
|
||||||
|
const type = snapshot.value?.state?.type ?? 0;
|
||||||
|
if (type === 0) return participant.leadership + participant.strength + participant.intel;
|
||||||
|
if (type === 1) return participant.leadership;
|
||||||
|
if (type === 2) return participant.strength;
|
||||||
|
return participant.intel;
|
||||||
|
};
|
||||||
|
const gamesOf = (participant: Snapshot['participants'][number] | undefined): number | '' =>
|
||||||
|
participant ? (participant.win ?? 0) + (participant.draw ?? 0) + (participant.lose ?? 0) : '';
|
||||||
|
const pointsOf = (participant: Snapshot['participants'][number] | undefined): number | '' =>
|
||||||
|
participant ? (participant.win ?? 0) * 3 + (participant.draw ?? 0) : '';
|
||||||
const currentMatch = computed(() => {
|
const currentMatch = computed(() => {
|
||||||
const state = snapshot.value?.state;
|
const state = snapshot.value?.state;
|
||||||
if (!state || state.stage < 7 || state.stage > 10) return null;
|
if (!state || state.stage < 7 || state.stage > 10) return null;
|
||||||
@@ -152,7 +166,7 @@ const start = async () => {
|
|||||||
<section class="operator-row bg0">운영자 메세지 : <span></span></section>
|
<section class="operator-row bg0">운영자 메세지 : <span></span></section>
|
||||||
<section class="state-row bg0">
|
<section class="state-row bg0">
|
||||||
<span class="type">{{ typeNames[snapshot?.state?.type ?? 0] }}</span>
|
<span class="type">{{ typeNames[snapshot?.state?.type ?? 0] }}</span>
|
||||||
({{ stageNames[snapshot?.state?.stage ?? 0] ?? '상태 확인 중' }}, 개막시간 {{ openingTime }}, 경기당
|
({{ resolveTournamentStageName(snapshot?.state?.stage ?? 0) }}, 개막시간 {{ openingTime }}, 경기당
|
||||||
{{ snapshot?.state?.termSeconds ?? '-' }}초)
|
{{ snapshot?.state?.termSeconds ?? '-' }}초)
|
||||||
</section>
|
</section>
|
||||||
<section class="section-title bg2">16강 승자전</section>
|
<section class="section-title bg2">16강 승자전</section>
|
||||||
@@ -164,7 +178,6 @@ const start = async () => {
|
|||||||
:winner-id="snapshot?.state?.winnerId"
|
:winner-id="snapshot?.state?.winnerId"
|
||||||
:bet-totals="betTotals"
|
:bet-totals="betTotals"
|
||||||
:total-bet="totalBet"
|
:total-bet="totalBet"
|
||||||
force-desktop
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section v-if="currentMatch" class="fight bg0">
|
<section v-if="currentMatch" class="fight bg0">
|
||||||
@@ -173,18 +186,35 @@ const start = async () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section-title groups-title bg2">조별 본선 순위</section>
|
<section class="section-title groups-title bg2">조별 본선 순위</section>
|
||||||
|
<div class="group-tabs bg0" role="tablist" aria-label="본선 조 선택">
|
||||||
|
<button
|
||||||
|
v-for="(groupName, groupIndex) in groupNames"
|
||||||
|
:key="`final-tab-${groupName}`"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeFinalGroup === groupIndex"
|
||||||
|
:class="{ active: activeFinalGroup === groupIndex }"
|
||||||
|
@click="activeFinalGroup = groupIndex"
|
||||||
|
>
|
||||||
|
{{ groupName }}조
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<section class="group-grid bg0">
|
<section class="group-grid bg0">
|
||||||
<table v-for="(group, groupIndex) in groups" :key="groupIndex">
|
<table
|
||||||
|
v-for="(group, groupIndex) in groups"
|
||||||
|
:key="groupIndex"
|
||||||
|
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
|
||||||
|
>
|
||||||
<caption>
|
<caption>
|
||||||
{{
|
{{
|
||||||
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex]
|
groupNames[groupIndex]
|
||||||
}}조
|
}}조
|
||||||
</caption>
|
</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>순</th>
|
<th>순</th>
|
||||||
<th>장수</th>
|
<th>장수</th>
|
||||||
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th>
|
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
|
||||||
<th>경</th>
|
<th>경</th>
|
||||||
<th>승</th>
|
<th>승</th>
|
||||||
<th>무</th>
|
<th>무</th>
|
||||||
@@ -196,26 +226,21 @@ const start = async () => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="rowIndex in 4" :key="rowIndex">
|
<tr v-for="rowIndex in 4" :key="rowIndex">
|
||||||
<td>{{ rowIndex }}</td>
|
<td>{{ rowIndex }}</td>
|
||||||
<td>{{ group[rowIndex - 1]?.name ?? '' }}</td>
|
<td class="general-cell">
|
||||||
<td>
|
<GeneralIdentity
|
||||||
{{
|
v-if="group[rowIndex - 1]"
|
||||||
group[rowIndex - 1]
|
:name="group[rowIndex - 1]!.name"
|
||||||
? (group[rowIndex - 1]!.win ?? 0) +
|
:picture="group[rowIndex - 1]!.picture"
|
||||||
(group[rowIndex - 1]!.draw ?? 0) +
|
:image-server="group[rowIndex - 1]!.imageServer"
|
||||||
(group[rowIndex - 1]!.lose ?? 0)
|
:icon-size="24"
|
||||||
: ''
|
/>
|
||||||
}}
|
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ statOf(group[rowIndex - 1]) }}</td>
|
||||||
|
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
|
||||||
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
||||||
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
||||||
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
||||||
<td>
|
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
|
||||||
{{
|
|
||||||
group[rowIndex - 1]
|
|
||||||
? (group[rowIndex - 1]!.win ?? 0) * 3 + (group[rowIndex - 1]!.draw ?? 0)
|
|
||||||
: ''
|
|
||||||
}}
|
|
||||||
</td>
|
|
||||||
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -223,18 +248,35 @@ const start = async () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section-title groups-title bg2">조별 예선 순위</section>
|
<section class="section-title groups-title bg2">조별 예선 순위</section>
|
||||||
|
<div class="group-tabs bg0" role="tablist" aria-label="예선 조 선택">
|
||||||
|
<button
|
||||||
|
v-for="(groupName, groupIndex) in groupNames"
|
||||||
|
:key="`preliminary-tab-${groupName}`"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activePreliminaryGroup === groupIndex"
|
||||||
|
:class="{ active: activePreliminaryGroup === groupIndex }"
|
||||||
|
@click="activePreliminaryGroup = groupIndex"
|
||||||
|
>
|
||||||
|
{{ groupName }}조
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<section class="group-grid preliminary-grid bg0">
|
<section class="group-grid preliminary-grid bg0">
|
||||||
<table v-for="groupIndex in 8" :key="`preliminary-${groupIndex}`">
|
<table
|
||||||
|
v-for="(group, groupIndex) in preliminaryGroups"
|
||||||
|
:key="`preliminary-${groupIndex}`"
|
||||||
|
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
|
||||||
|
>
|
||||||
<caption>
|
<caption>
|
||||||
{{
|
{{
|
||||||
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex - 1]
|
groupNames[groupIndex]
|
||||||
}}조
|
}}조
|
||||||
</caption>
|
</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>순</th>
|
<th>순</th>
|
||||||
<th>장수</th>
|
<th>장수</th>
|
||||||
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th>
|
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
|
||||||
<th>경</th>
|
<th>경</th>
|
||||||
<th>승</th>
|
<th>승</th>
|
||||||
<th>무</th>
|
<th>무</th>
|
||||||
@@ -246,14 +288,22 @@ const start = async () => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="rowIndex in 8" :key="rowIndex">
|
<tr v-for="rowIndex in 8" :key="rowIndex">
|
||||||
<td>{{ rowIndex }}</td>
|
<td>{{ rowIndex }}</td>
|
||||||
<td></td>
|
<td class="general-cell">
|
||||||
<td></td>
|
<GeneralIdentity
|
||||||
<td></td>
|
v-if="group[rowIndex - 1]"
|
||||||
<td></td>
|
:name="group[rowIndex - 1]!.name"
|
||||||
<td></td>
|
:picture="group[rowIndex - 1]!.picture"
|
||||||
<td></td>
|
:image-server="group[rowIndex - 1]!.imageServer"
|
||||||
<td></td>
|
:icon-size="24"
|
||||||
<td></td>
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{{ statOf(group[rowIndex - 1]) }}</td>
|
||||||
|
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
|
||||||
|
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
||||||
|
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
||||||
|
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
||||||
|
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
|
||||||
|
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -287,8 +337,7 @@ const start = async () => {
|
|||||||
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<small>
|
<small>
|
||||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
|
||||||
HideD(hided62@gmail.com) / Credit
|
|
||||||
</small>
|
</small>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -302,9 +351,10 @@ const start = async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.legacy-page {
|
.legacy-page {
|
||||||
width: 2009px;
|
width: 100%;
|
||||||
height: 1059px;
|
max-width: 1200px;
|
||||||
overflow: hidden;
|
min-width: 0;
|
||||||
|
min-height: 100vh;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: var(--sammo-font-sans);
|
font-family: var(--sammo-font-sans);
|
||||||
@@ -431,13 +481,15 @@ button:focus-visible {
|
|||||||
}
|
}
|
||||||
.group-grid {
|
.group-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(8, 250px);
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
}
|
}
|
||||||
table {
|
table {
|
||||||
width: 250px;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: auto;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
caption {
|
caption {
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
@@ -450,14 +502,99 @@ th {
|
|||||||
}
|
}
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
height: 17px;
|
height: 30px;
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
padding: 1px 3px;
|
padding: 1px 3px;
|
||||||
}
|
}
|
||||||
|
.group-grid th:first-child,
|
||||||
|
.group-grid td:first-child {
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
.group-grid th:nth-child(2),
|
||||||
|
.group-grid td:nth-child(2) {
|
||||||
|
width: 92px;
|
||||||
|
}
|
||||||
|
.general-cell {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.group-tabs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.admin-row {
|
.admin-row {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.error-row {
|
.error-row {
|
||||||
color: #ff8080;
|
color: #ff8080;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.legacy-page {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.legacy-title {
|
||||||
|
height: auto;
|
||||||
|
min-height: 55px;
|
||||||
|
}
|
||||||
|
.state-row {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.group-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, minmax(44px, 1fr));
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 6px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.group-tabs button {
|
||||||
|
min-width: 44px;
|
||||||
|
height: 34px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.group-tabs button.active {
|
||||||
|
border-color: #f39c12;
|
||||||
|
background: #8a5b13;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.group-grid {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.group-grid table {
|
||||||
|
display: none;
|
||||||
|
min-width: 370px;
|
||||||
|
}
|
||||||
|
.group-grid table.mobile-active {
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
.group-grid th,
|
||||||
|
.group-grid td {
|
||||||
|
height: 31px;
|
||||||
|
padding: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.group-grid th:first-child,
|
||||||
|
.group-grid td:first-child {
|
||||||
|
width: 22px;
|
||||||
|
}
|
||||||
|
.group-grid th:nth-child(2),
|
||||||
|
.group-grid td:nth-child(2) {
|
||||||
|
width: 108px;
|
||||||
|
}
|
||||||
|
.tournament-guide {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
.tournament-footer {
|
||||||
|
padding: 10px 0 0;
|
||||||
|
}
|
||||||
|
.tournament-footer small {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
@@ -50,10 +51,7 @@ const onlineRows = computed(() =>
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const timeLabel = (value: string): string => {
|
const timeLabel = (value: string): string => formatServerDateTime(value, { format: 'hourMinute' });
|
||||||
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
|
|
||||||
return (timePart ?? '').slice(0, 5);
|
|
||||||
};
|
|
||||||
|
|
||||||
const trafficColor = (percentage: number): string => {
|
const trafficColor = (percentage: number): string => {
|
||||||
const channel = (value: number): string =>
|
const channel = (value: number): string =>
|
||||||
@@ -204,8 +202,8 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="banner">
|
<td class="banner">
|
||||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
|
||||||
HideD(hided62@gmail.com) /
|
/
|
||||||
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
|
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
@@ -166,10 +167,7 @@ const hideMemberPopup = () => {
|
|||||||
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
|
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
|
||||||
|
|
||||||
const formatTurn = (turnTime: string | null): string => {
|
const formatTurn = (turnTime: string | null): string => {
|
||||||
if (!turnTime) {
|
return formatServerDateTime(turnTime, { format: 'minuteSecond', fallback: '--:--' });
|
||||||
return '--:--';
|
|
||||||
}
|
|
||||||
return turnTime.slice(14, 19);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
commandArgumentPresentation,
|
||||||
|
presentedCommandKeys,
|
||||||
|
} from '../src/components/command/commandArgumentPresentation.ts';
|
||||||
|
|
||||||
|
const cityCommands = [
|
||||||
|
'che_강행',
|
||||||
|
'che_이동',
|
||||||
|
'che_출병',
|
||||||
|
'che_첩보',
|
||||||
|
'che_화계',
|
||||||
|
'che_탈취',
|
||||||
|
'che_파괴',
|
||||||
|
'che_선동',
|
||||||
|
'che_수몰',
|
||||||
|
'che_백성동원',
|
||||||
|
'che_천도',
|
||||||
|
'che_허보',
|
||||||
|
'che_초토화',
|
||||||
|
'cr_인구이동',
|
||||||
|
'che_발령',
|
||||||
|
];
|
||||||
|
|
||||||
|
const nationCommands = [
|
||||||
|
'che_선전포고',
|
||||||
|
'che_급습',
|
||||||
|
'che_불가침파기제의',
|
||||||
|
'che_이호경식',
|
||||||
|
'che_종전제의',
|
||||||
|
'che_불가침제의',
|
||||||
|
'che_피장파장',
|
||||||
|
'che_물자원조',
|
||||||
|
];
|
||||||
|
|
||||||
|
const otherArgumentCommands = [
|
||||||
|
'che_증여',
|
||||||
|
'che_헌납',
|
||||||
|
'che_군량매매',
|
||||||
|
'che_몰수',
|
||||||
|
'che_포상',
|
||||||
|
'che_부대탈퇴지시',
|
||||||
|
'che_등용',
|
||||||
|
'che_선양',
|
||||||
|
'che_임관',
|
||||||
|
'che_장수대상임관',
|
||||||
|
'che_숙련전환',
|
||||||
|
'che_장비매매',
|
||||||
|
'che_건국',
|
||||||
|
'che_무작위건국',
|
||||||
|
'cr_건국',
|
||||||
|
'che_국기변경',
|
||||||
|
'che_국호변경',
|
||||||
|
'che_등용수락',
|
||||||
|
'che_NPC능동',
|
||||||
|
];
|
||||||
|
|
||||||
|
void test('provides Ref-level guidance for every in-scope argument command', () => {
|
||||||
|
const expected = [...cityCommands, ...nationCommands, ...otherArgumentCommands].sort();
|
||||||
|
assert.deepEqual(presentedCommandKeys().sort(), expected);
|
||||||
|
for (const commandKey of expected) {
|
||||||
|
assert.ok(commandArgumentPresentation(commandKey).lines.join(' ').length >= 12, commandKey);
|
||||||
|
}
|
||||||
|
assert.ok(!presentedCommandKeys().includes('che_징병'));
|
||||||
|
assert.ok(!presentedCommandKeys().includes('che_모병'));
|
||||||
|
assert.deepEqual(commandArgumentPresentation('che_징병'), { lines: [] });
|
||||||
|
assert.deepEqual(commandArgumentPresentation('che_모병'), { lines: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('marks the same city and nation target families that Ref renders with a map', () => {
|
||||||
|
for (const commandKey of cityCommands) {
|
||||||
|
assert.equal(commandArgumentPresentation(commandKey).mapTarget, 'city', commandKey);
|
||||||
|
}
|
||||||
|
for (const commandKey of nationCommands) {
|
||||||
|
assert.equal(commandArgumentPresentation(commandKey).mapTarget, 'nation', commandKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DISPLAY_SETTINGS_VERSION,
|
||||||
|
compareGridValues,
|
||||||
|
defaultNationGeneralDisplaySettings,
|
||||||
|
matchesKoreanSearch,
|
||||||
|
matchesNumberSearch,
|
||||||
|
parseStoredDisplaySettings,
|
||||||
|
parseStoredSettingKey,
|
||||||
|
serializeDisplaySettings,
|
||||||
|
} from '../src/utils/nationGeneralGrid.ts';
|
||||||
|
|
||||||
|
void describe('nation general Ref-compatible grid state', () => {
|
||||||
|
void it('keeps the Ref default group and sort state', () => {
|
||||||
|
const normal = defaultNationGeneralDisplaySettings.normal;
|
||||||
|
assert.equal(normal.columnGroup.find((entry) => entry.groupId === 'stat')?.open, true);
|
||||||
|
assert.equal(normal.columnGroup.find((entry) => entry.groupId === 'specials')?.open, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
normal.column.filter((entry) => entry.sort).map((entry) => [entry.colId, entry.sort, entry.sortIndex]),
|
||||||
|
[['refreshScoreTotal', 'desc', 0]]
|
||||||
|
);
|
||||||
|
const war = defaultNationGeneralDisplaySettings.war;
|
||||||
|
assert.equal(war.columnGroup.find((entry) => entry.groupId === 'stat')?.open, false);
|
||||||
|
assert.equal(war.column.find((entry) => entry.colId === 'stat_1')?.hide, false);
|
||||||
|
assert.equal(war.column.find((entry) => entry.colId === 'leadership')?.hide, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void it('round-trips named settings and rejects invalid versions', () => {
|
||||||
|
const settings = new Map([['전투 보기', defaultNationGeneralDisplaySettings.war]]);
|
||||||
|
const restored = parseStoredDisplaySettings(serializeDisplaySettings(settings));
|
||||||
|
assert.equal(restored.get('전투 보기')?.column.find((entry) => entry.colId === 'icon')?.hide, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
parseStoredDisplaySettings(JSON.stringify({ version: DISPLAY_SETTINGS_VERSION + 1, settings: [] })),
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
assert.deepEqual(parseStoredDisplaySettings('{broken'), new Map());
|
||||||
|
});
|
||||||
|
|
||||||
|
void it('accepts only valid last-used setting tuples', () => {
|
||||||
|
assert.deepEqual(parseStoredSettingKey('[true,"normal"]'), [true, 'normal']);
|
||||||
|
assert.deepEqual(parseStoredSettingKey('[false,"내 설정"]'), [false, '내 설정']);
|
||||||
|
assert.equal(parseStoredSettingKey('[true,"missing"]'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
void it('matches Korean names by text and initial consonants', () => {
|
||||||
|
assert.equal(matchesKoreanSearch('테스트장수', '테스트'), true);
|
||||||
|
assert.equal(matchesKoreanSearch('테스트장수', 'ㅌㅅㅌㅈㅅ'), true);
|
||||||
|
assert.equal(matchesKoreanSearch('테스트장수', 'ㄱㄴ'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void it('uses Ref-like numeric comparisons and stable Korean text ordering', () => {
|
||||||
|
assert.equal(matchesNumberSearch(90, '90'), true);
|
||||||
|
assert.equal(matchesNumberSearch(90, '>= 80'), true);
|
||||||
|
assert.equal(matchesNumberSearch(90, '< 80'), false);
|
||||||
|
assert.equal(compareGridValues(10, 2) > 0, true);
|
||||||
|
assert.equal(compareGridValues(null, 2) > 0, true);
|
||||||
|
assert.equal(compareGridValues('가', '나') < 0, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,12 @@ import { describe, it } from 'node:test';
|
|||||||
|
|
||||||
import { buildTournamentBracket } from '../src/utils/tournamentBracket.ts';
|
import { buildTournamentBracket } from '../src/utils/tournamentBracket.ts';
|
||||||
|
|
||||||
const participants = Array.from({ length: 16 }, (_, index) => ({ id: index + 1, name: `장수${index + 1}` }));
|
const participants = Array.from({ length: 16 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
name: `장수${index + 1}`,
|
||||||
|
picture: `${index + 1}.jpg`,
|
||||||
|
imageServer: index % 2,
|
||||||
|
}));
|
||||||
const matches = [
|
const matches = [
|
||||||
...Array.from({ length: 8 }, (_, index) => ({
|
...Array.from({ length: 8 }, (_, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
@@ -37,6 +42,8 @@ void describe('tournament bracket', () => {
|
|||||||
const bracket = buildTournamentBracket(participants, matches, 1);
|
const bracket = buildTournamentBracket(participants, matches, 1);
|
||||||
|
|
||||||
assert.equal(bracket.champion.name, '장수1');
|
assert.equal(bracket.champion.name, '장수1');
|
||||||
|
assert.equal(bracket.champion.picture, '1.jpg');
|
||||||
|
assert.equal(bracket.top16.slots[1]?.imageServer, 1);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
bracket.top16.slots.map((slot) => slot.name),
|
bracket.top16.slots.map((slot) => slot.name),
|
||||||
participants.map((participant) => participant.name)
|
participants.map((participant) => participant.name)
|
||||||
@@ -52,10 +59,16 @@ void describe('tournament bracket', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
void it('renders missing future rounds as stable empty slots without inventing generals', () => {
|
void it('renders missing future rounds as stable empty slots without inventing generals', () => {
|
||||||
const bracket = buildTournamentBracket(participants, matches.filter((match) => match.stage === 7));
|
const bracket = buildTournamentBracket(
|
||||||
|
participants,
|
||||||
|
matches.filter((match) => match.stage === 7)
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(bracket.champion.name, '-');
|
assert.equal(bracket.champion.name, '-');
|
||||||
assert.deepEqual(bracket.final.slots.map((slot) => slot.name), ['-', '-']);
|
assert.deepEqual(
|
||||||
|
bracket.final.slots.map((slot) => slot.name),
|
||||||
|
['-', '-']
|
||||||
|
);
|
||||||
assert.equal(bracket.top16.slots[0]?.name, '장수1');
|
assert.equal(bracket.top16.slots[0]?.name, '장수1');
|
||||||
assert.equal(bracket.top16.slots[15]?.name, '장수16');
|
assert.equal(bracket.top16.slots[15]?.name, '장수16');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { resolveTournamentStageName } from '../src/utils/tournamentStatus.ts';
|
||||||
|
|
||||||
|
void describe('tournament status labels', () => {
|
||||||
|
void it('describes inactive and active tournament stages', () => {
|
||||||
|
assert.equal(resolveTournamentStageName(0), '경기 없음');
|
||||||
|
assert.equal(resolveTournamentStageName(1), '참가 모집중');
|
||||||
|
assert.equal(resolveTournamentStageName(6), '베팅 진행중');
|
||||||
|
assert.equal(resolveTournamentStageName(10), '결승 진행중');
|
||||||
|
});
|
||||||
|
|
||||||
|
void it('uses a safe fallback for an unknown stage', () => {
|
||||||
|
assert.equal(resolveTournamentStageName(11), '상태 확인 중');
|
||||||
|
assert.equal(resolveTournamentStageName(-1), '상태 확인 중');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1280,7 +1280,10 @@ export const adminRouter = router({
|
|||||||
sourceRef = resolved;
|
sourceRef = resolved;
|
||||||
}
|
}
|
||||||
const scenarios = await listScenarioPreviews({ gitRef: resolved });
|
const scenarios = await listScenarioPreviews({ gitRef: resolved });
|
||||||
if (!scenarios.some((scenario) => String(scenario.id) === profile.scenario)) {
|
if (
|
||||||
|
profile.currentScenario === null ||
|
||||||
|
!scenarios.some((scenario) => String(scenario.id) === profile.currentScenario)
|
||||||
|
) {
|
||||||
throw new Error('Current scenario is not available at source.');
|
throw new Error('Current scenario is not available at source.');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1579,6 +1582,8 @@ export const adminRouter = router({
|
|||||||
.map((profile) => ({
|
.map((profile) => ({
|
||||||
profileName: profile.profileName,
|
profileName: profile.profileName,
|
||||||
profile: profile.profile,
|
profile: profile.profile,
|
||||||
|
instanceKey: profile.instanceKey,
|
||||||
|
currentScenario: profile.currentScenario,
|
||||||
meta: {
|
meta: {
|
||||||
...(typeof profile.meta.korName === 'string' ? { korName: profile.meta.korName } : {}),
|
...(typeof profile.meta.korName === 'string' ? { korName: profile.meta.korName } : {}),
|
||||||
},
|
},
|
||||||
@@ -1661,7 +1666,8 @@ export const adminRouter = router({
|
|||||||
);
|
);
|
||||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||||
if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||||
const parsedScenarioId = Number(profile.scenario);
|
const parsedScenarioId =
|
||||||
|
profile.currentScenario === null ? Number.NaN : Number(profile.currentScenario);
|
||||||
currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null;
|
currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null;
|
||||||
gitRef = profile.buildCommitSha?.trim();
|
gitRef = profile.buildCommitSha?.trim();
|
||||||
if (!gitRef) {
|
if (!gitRef) {
|
||||||
@@ -1692,8 +1698,13 @@ export const adminRouter = router({
|
|||||||
upsert: profileAdminProcedure
|
upsert: profileAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
profile: z.string().min(1).max(32),
|
profile: z.string().regex(/^[a-z0-9-]{1,32}$/),
|
||||||
scenario: z.string().min(1).max(64),
|
instanceKey: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-z0-9-]{1,64}$/)
|
||||||
|
.optional(),
|
||||||
|
currentScenario: z.string().min(1).max(64).nullable().optional(),
|
||||||
|
scenario: z.string().min(1).max(64).optional(),
|
||||||
apiPort: z.number().int().min(1).max(65535),
|
apiPort: z.number().int().min(1).max(65535),
|
||||||
status: zProfileStatus.optional(),
|
status: zProfileStatus.optional(),
|
||||||
preopenAt: z.string().datetime().optional(),
|
preopenAt: z.string().datetime().optional(),
|
||||||
@@ -1706,6 +1717,8 @@ export const adminRouter = router({
|
|||||||
const status = input.status ?? 'STOPPED';
|
const status = input.status ?? 'STOPPED';
|
||||||
return ctx.profiles.upsertProfile({
|
return ctx.profiles.upsertProfile({
|
||||||
profile: input.profile,
|
profile: input.profile,
|
||||||
|
instanceKey: input.instanceKey,
|
||||||
|
currentScenario: input.currentScenario,
|
||||||
scenario: input.scenario,
|
scenario: input.scenario,
|
||||||
apiPort: input.apiPort,
|
apiPort: input.apiPort,
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export type LobbyGeneralStatus = {
|
|||||||
export type LobbyProfileStatus = {
|
export type LobbyProfileStatus = {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
|
instanceKey: string;
|
||||||
|
currentScenario: string | null;
|
||||||
|
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
||||||
scenario: string;
|
scenario: string;
|
||||||
status: GatewayProfileStatus;
|
status: GatewayProfileStatus;
|
||||||
apiPort: number;
|
apiPort: number;
|
||||||
@@ -87,6 +90,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
return {
|
return {
|
||||||
profileName: row.profileName,
|
profileName: row.profileName,
|
||||||
profile: row.profile,
|
profile: row.profile,
|
||||||
|
instanceKey: row.instanceKey,
|
||||||
|
currentScenario: row.currentScenario,
|
||||||
scenario: row.scenario,
|
scenario: row.scenario,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
apiPort: row.apiPort,
|
apiPort: row.apiPort,
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ export const buildProcessDefinitions = (
|
|||||||
...baseEnv,
|
...baseEnv,
|
||||||
GAME_API_ROLE: 'server',
|
GAME_API_ROLE: 'server',
|
||||||
PROFILE: profile.profile,
|
PROFILE: profile.profile,
|
||||||
SCENARIO: profile.scenario,
|
SCENARIO: profile.currentScenario ?? 'default',
|
||||||
GAME_PROFILE_NAME: profile.profileName,
|
GAME_PROFILE_NAME: profile.profileName,
|
||||||
GAME_API_PORT: String(profile.apiPort),
|
GAME_API_PORT: String(profile.apiPort),
|
||||||
GAME_TRPC_PATH: `/${profile.profile}/api/trpc`,
|
GAME_TRPC_PATH: `/${profile.profile}/api/trpc`,
|
||||||
@@ -411,7 +411,7 @@ export const buildProcessDefinitions = (
|
|||||||
GAME_ENGINE_ROLE: 'turn-daemon',
|
GAME_ENGINE_ROLE: 'turn-daemon',
|
||||||
TURN_PROFILE: profile.profile,
|
TURN_PROFILE: profile.profile,
|
||||||
PROFILE: profile.profile,
|
PROFILE: profile.profile,
|
||||||
SCENARIO: profile.scenario,
|
SCENARIO: profile.currentScenario ?? 'default',
|
||||||
TURN_PROFILE_NAME: profile.profileName,
|
TURN_PROFILE_NAME: profile.profileName,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
@@ -1422,8 +1422,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
} = parseInstallOptions(action);
|
} = parseInstallOptions(action);
|
||||||
const tickOverride =
|
const tickOverride =
|
||||||
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
|
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
|
||||||
const scenarioId = installScenarioId ?? parseScenarioId(profile.scenario);
|
const scenarioId = installScenarioId ?? parseScenarioId(profile.currentScenario);
|
||||||
if (!scenarioId) {
|
if (scenarioId === null) {
|
||||||
return { status: 'FAILED', detail: 'scenarioId is missing' };
|
return { status: 'FAILED', detail: 'scenarioId is missing' };
|
||||||
}
|
}
|
||||||
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
|
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
|
||||||
@@ -1547,7 +1547,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
|
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
|
||||||
const publishedProfile = await updateClaimedProfile(
|
const publishedProfile = await updateClaimedProfile(
|
||||||
{
|
{
|
||||||
scenario: String(scenarioId),
|
currentScenario: String(scenarioId),
|
||||||
status: desiredStatus,
|
status: desiredStatus,
|
||||||
buildStatus: 'SUCCEEDED',
|
buildStatus: 'SUCCEEDED',
|
||||||
buildWorkspace: workspace.root,
|
buildWorkspace: workspace.root,
|
||||||
@@ -1564,8 +1564,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
completedAt,
|
completedAt,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
if (String(scenarioId) !== profile.scenario) {
|
if (String(scenarioId) !== profile.currentScenario) {
|
||||||
await this.repository.updateScenario(profile.profileName, String(scenarioId));
|
await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId));
|
||||||
}
|
}
|
||||||
return this.repository.updateStatus(profile.profileName, desiredStatus, {
|
return this.repository.updateStatus(profile.profileName, desiredStatus, {
|
||||||
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
|
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
|
||||||
@@ -1577,6 +1577,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
releasePrepared = true;
|
releasePrepared = true;
|
||||||
const builtProfile = publishedProfile ?? {
|
const builtProfile = publishedProfile ?? {
|
||||||
...profile,
|
...profile,
|
||||||
|
currentScenario: String(scenarioId),
|
||||||
scenario: String(scenarioId),
|
scenario: String(scenarioId),
|
||||||
status: desiredStatus,
|
status: desiredStatus,
|
||||||
buildWorkspace: workspace.root,
|
buildWorkspace: workspace.root,
|
||||||
@@ -1642,7 +1643,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
meta: Record<string, unknown>;
|
meta: Record<string, unknown>;
|
||||||
}> {
|
}> {
|
||||||
const databaseUrl = databaseUrlOverride ?? this.resolveProfileDatabaseUrl(profile);
|
const databaseUrl = databaseUrlOverride ?? this.resolveProfileDatabaseUrl(profile);
|
||||||
let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.scenario);
|
let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.currentScenario);
|
||||||
let tickSeconds: number | undefined = overrides?.tickSeconds;
|
let tickSeconds: number | undefined = overrides?.tickSeconds;
|
||||||
let meta: Record<string, unknown> = {};
|
let meta: Record<string, unknown> = {};
|
||||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ export interface GatewayOperationLogInput {
|
|||||||
export interface GatewayProfileRecord {
|
export interface GatewayProfileRecord {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
|
instanceKey: string;
|
||||||
|
currentScenario: string | null;
|
||||||
|
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
||||||
scenario: string;
|
scenario: string;
|
||||||
apiPort: number;
|
apiPort: number;
|
||||||
status: GatewayProfileStatus;
|
status: GatewayProfileStatus;
|
||||||
@@ -100,7 +103,10 @@ export interface GatewayProfileRecord {
|
|||||||
|
|
||||||
export interface GatewayProfileUpsertInput {
|
export interface GatewayProfileUpsertInput {
|
||||||
profile: string;
|
profile: string;
|
||||||
scenario: string;
|
instanceKey?: string;
|
||||||
|
currentScenario?: string | null;
|
||||||
|
/** @deprecated Accepted while older bootstrap clients are still supported. */
|
||||||
|
scenario?: string;
|
||||||
apiPort: number;
|
apiPort: number;
|
||||||
status?: GatewayProfileStatus;
|
status?: GatewayProfileStatus;
|
||||||
preopenAt?: string;
|
preopenAt?: string;
|
||||||
@@ -111,7 +117,7 @@ export interface GatewayProfileUpsertInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayClaimedProfileUpdate {
|
export interface GatewayClaimedProfileUpdate {
|
||||||
scenario?: string;
|
currentScenario?: string | null;
|
||||||
status?: GatewayProfileStatus;
|
status?: GatewayProfileStatus;
|
||||||
buildStatus?: GatewayBuildStatus;
|
buildStatus?: GatewayBuildStatus;
|
||||||
buildCommitSha?: string | null;
|
buildCommitSha?: string | null;
|
||||||
@@ -131,7 +137,7 @@ export interface GatewayProfileRepository {
|
|||||||
listProfiles(): Promise<GatewayProfileRecord[]>;
|
listProfiles(): Promise<GatewayProfileRecord[]>;
|
||||||
getProfile(profileName: string): Promise<GatewayProfileRecord | null>;
|
getProfile(profileName: string): Promise<GatewayProfileRecord | null>;
|
||||||
upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>;
|
upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>;
|
||||||
updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null>;
|
updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null>;
|
||||||
updateStatus(
|
updateStatus(
|
||||||
profileName: string,
|
profileName: string,
|
||||||
status: GatewayProfileStatus,
|
status: GatewayProfileStatus,
|
||||||
@@ -219,6 +225,8 @@ export const buildRetryOperationSource = (previous: {
|
|||||||
type GatewayProfileRow = {
|
type GatewayProfileRow = {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
|
instanceKey: string;
|
||||||
|
currentScenario: string | null;
|
||||||
scenario: string;
|
scenario: string;
|
||||||
apiPort: number;
|
apiPort: number;
|
||||||
status: GatewayProfileStatus;
|
status: GatewayProfileStatus;
|
||||||
@@ -265,6 +273,8 @@ type GatewayOperationRow = {
|
|||||||
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
|
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
|
||||||
profileName: row.profileName,
|
profileName: row.profileName,
|
||||||
profile: row.profile,
|
profile: row.profile,
|
||||||
|
instanceKey: row.instanceKey,
|
||||||
|
currentScenario: row.currentScenario,
|
||||||
scenario: row.scenario,
|
scenario: row.scenario,
|
||||||
apiPort: row.apiPort,
|
apiPort: row.apiPort,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
@@ -285,7 +295,24 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
|
|||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildProfileName = (profile: string, scenario: string): string => `${profile}:${scenario}`;
|
export const buildGatewayProfileName = (profile: string, instanceKey: string): string => `${profile}:${instanceKey}`;
|
||||||
|
|
||||||
|
export const resolveGatewayProfileIdentity = (
|
||||||
|
input: GatewayProfileUpsertInput
|
||||||
|
): {
|
||||||
|
instanceKey: string;
|
||||||
|
currentScenario: string | null;
|
||||||
|
shouldUpdateCurrentScenario: boolean;
|
||||||
|
} => {
|
||||||
|
const instanceKey = input.instanceKey ?? input.scenario ?? 'default';
|
||||||
|
if (input.currentScenario !== undefined) {
|
||||||
|
return { instanceKey, currentScenario: input.currentScenario, shouldUpdateCurrentScenario: true };
|
||||||
|
}
|
||||||
|
if (input.instanceKey === undefined && input.scenario !== undefined && input.scenario !== 'default') {
|
||||||
|
return { instanceKey, currentScenario: input.scenario, shouldUpdateCurrentScenario: true };
|
||||||
|
}
|
||||||
|
return { instanceKey, currentScenario: null, shouldUpdateCurrentScenario: false };
|
||||||
|
};
|
||||||
|
|
||||||
const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({
|
const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -331,7 +358,7 @@ const mapOperationLog = (row: {
|
|||||||
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
|
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
|
||||||
async listProfiles(): Promise<GatewayProfileRecord[]> {
|
async listProfiles(): Promise<GatewayProfileRecord[]> {
|
||||||
const rows = await prisma.gatewayProfile.findMany({
|
const rows = await prisma.gatewayProfile.findMany({
|
||||||
orderBy: [{ profile: 'asc' }, { scenario: 'asc' }],
|
orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }],
|
||||||
});
|
});
|
||||||
return rows.map(mapProfile);
|
return rows.map(mapProfile);
|
||||||
},
|
},
|
||||||
@@ -342,13 +369,16 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
return row ? mapProfile(row) : null;
|
return row ? mapProfile(row) : null;
|
||||||
},
|
},
|
||||||
async upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord> {
|
async upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord> {
|
||||||
const profileName = buildProfileName(input.profile, input.scenario);
|
const { instanceKey, currentScenario, shouldUpdateCurrentScenario } = resolveGatewayProfileIdentity(input);
|
||||||
|
const profileName = buildGatewayProfileName(input.profile, instanceKey);
|
||||||
const row = await prisma.gatewayProfile.upsert({
|
const row = await prisma.gatewayProfile.upsert({
|
||||||
where: { profileName },
|
where: { profileName },
|
||||||
create: {
|
create: {
|
||||||
profileName,
|
profileName,
|
||||||
profile: input.profile,
|
profile: input.profile,
|
||||||
scenario: input.scenario,
|
instanceKey,
|
||||||
|
currentScenario,
|
||||||
|
scenario: currentScenario ?? 'default',
|
||||||
apiPort: input.apiPort,
|
apiPort: input.apiPort,
|
||||||
status: input.status ?? 'STOPPED',
|
status: input.status ?? 'STOPPED',
|
||||||
preopenAt: input.preopenAt ? new Date(input.preopenAt) : null,
|
preopenAt: input.preopenAt ? new Date(input.preopenAt) : null,
|
||||||
@@ -358,6 +388,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
meta: (input.meta ?? {}) as GatewayPrisma.JsonObject,
|
meta: (input.meta ?? {}) as GatewayPrisma.JsonObject,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
|
currentScenario: shouldUpdateCurrentScenario ? currentScenario : undefined,
|
||||||
|
scenario: shouldUpdateCurrentScenario ? (currentScenario ?? 'default') : undefined,
|
||||||
apiPort: input.apiPort,
|
apiPort: input.apiPort,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
preopenAt: input.preopenAt ? new Date(input.preopenAt) : input.preopenAt === null ? null : undefined,
|
preopenAt: input.preopenAt ? new Date(input.preopenAt) : input.preopenAt === null ? null : undefined,
|
||||||
@@ -373,11 +405,12 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
});
|
});
|
||||||
return mapProfile(row);
|
return mapProfile(row);
|
||||||
},
|
},
|
||||||
async updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null> {
|
async updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null> {
|
||||||
const row = await prisma.gatewayProfile.update({
|
const row = await prisma.gatewayProfile.update({
|
||||||
where: { profileName },
|
where: { profileName },
|
||||||
data: {
|
data: {
|
||||||
scenario,
|
currentScenario: scenario,
|
||||||
|
scenario: scenario ?? 'default',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return row ? mapProfile(row) : null;
|
return row ? mapProfile(row) : null;
|
||||||
@@ -700,7 +733,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
return tx.gatewayProfile.update({
|
return tx.gatewayProfile.update({
|
||||||
where: { profileName },
|
where: { profileName },
|
||||||
data: {
|
data: {
|
||||||
scenario: patch.scenario,
|
currentScenario: patch.currentScenario,
|
||||||
|
scenario: patch.currentScenario === undefined ? undefined : (patch.currentScenario ?? 'default'),
|
||||||
status: patch.status,
|
status: patch.status,
|
||||||
buildStatus: patch.buildStatus,
|
buildStatus: patch.buildStatus,
|
||||||
buildCommitSha: patch.buildCommitSha,
|
buildCommitSha: patch.buildCommitSha,
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ export const GATEWAY_PROFILE_ORDER = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya',
|
|||||||
const gatewayProfileOrder = new Map<string, number>(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index]));
|
const gatewayProfileOrder = new Map<string, number>(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index]));
|
||||||
|
|
||||||
export const compareGatewayProfiles = (
|
export const compareGatewayProfiles = (
|
||||||
left: { profile: string; scenario: string },
|
left: { profile: string; instanceKey: string },
|
||||||
right: { profile: string; scenario: string }
|
right: { profile: string; instanceKey: string }
|
||||||
): number => {
|
): number => {
|
||||||
const unknownRank = GATEWAY_PROFILE_ORDER.length;
|
const unknownRank = GATEWAY_PROFILE_ORDER.length;
|
||||||
const profileOrder =
|
const profileOrder =
|
||||||
@@ -14,8 +14,8 @@ export const compareGatewayProfiles = (
|
|||||||
|
|
||||||
const profileNameOrder = left.profile.localeCompare(right.profile);
|
const profileNameOrder = left.profile.localeCompare(right.profile);
|
||||||
if (profileNameOrder !== 0) return profileNameOrder;
|
if (profileNameOrder !== 0) return profileNameOrder;
|
||||||
return left.scenario.localeCompare(right.scenario);
|
return left.instanceKey.localeCompare(right.instanceKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const orderGatewayProfiles = <T extends { profile: string; scenario: string }>(profiles: readonly T[]): T[] =>
|
export const orderGatewayProfiles = <T extends { profile: string; instanceKey: string }>(profiles: readonly T[]): T[] =>
|
||||||
[...profiles].sort(compareGatewayProfiles);
|
[...profiles].sort(compareGatewayProfiles);
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ const buildCaller = async (
|
|||||||
const profile = {
|
const profile = {
|
||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
|
instanceKey: '2',
|
||||||
|
currentScenario: options.profileScenario ?? '2',
|
||||||
scenario: options.profileScenario ?? '2',
|
scenario: options.profileScenario ?? '2',
|
||||||
apiPort: 15003,
|
apiPort: 15003,
|
||||||
status: options.initialProfileStatus ?? ('STOPPED' as const),
|
status: options.initialProfileStatus ?? ('STOPPED' as const),
|
||||||
@@ -98,7 +100,7 @@ const buildCaller = async (
|
|||||||
listProfiles: async () => [profile],
|
listProfiles: async () => [profile],
|
||||||
getProfile: async () => profile,
|
getProfile: async () => profile,
|
||||||
upsertProfile: async () => profile,
|
upsertProfile: async () => profile,
|
||||||
updateScenario: async () => profile,
|
updateCurrentScenario: async () => profile,
|
||||||
updateStatus: async (_profileName, status) => {
|
updateStatus: async (_profileName, status) => {
|
||||||
updatedStatuses.push(status);
|
updatedStatuses.push(status);
|
||||||
return { ...profile, status };
|
return { ...profile, status };
|
||||||
@@ -362,6 +364,8 @@ describe('admin profile navigation API', () => {
|
|||||||
{
|
{
|
||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
|
instanceKey: '2',
|
||||||
|
currentScenario: '2',
|
||||||
meta: {},
|
meta: {},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { appRouter } from '../src/router.js';
|
|||||||
const profile = {
|
const profile = {
|
||||||
profileName: 'che:default',
|
profileName: 'che:default',
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
|
instanceKey: 'default',
|
||||||
|
currentScenario: null,
|
||||||
scenario: 'default',
|
scenario: 'default',
|
||||||
apiPort: 15003,
|
apiPort: 15003,
|
||||||
status: 'RUNNING' as const,
|
status: 'RUNNING' as const,
|
||||||
@@ -28,7 +30,7 @@ const profiles: GatewayProfileRepository = {
|
|||||||
listProfiles: async () => [profile],
|
listProfiles: async () => [profile],
|
||||||
getProfile: async (profileName) => (profileName === profile.profileName ? profile : null),
|
getProfile: async (profileName) => (profileName === profile.profileName ? profile : null),
|
||||||
upsertProfile: async () => profile,
|
upsertProfile: async () => profile,
|
||||||
updateScenario: async () => profile,
|
updateCurrentScenario: async () => profile,
|
||||||
updateStatus: async () => profile,
|
updateStatus: async () => profile,
|
||||||
updateBuildStatus: async () => profile,
|
updateBuildStatus: async () => profile,
|
||||||
updateMeta: async () => profile,
|
updateMeta: async () => profile,
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ const buildCaller = (
|
|||||||
{
|
{
|
||||||
profileName: 'che:default',
|
profileName: 'che:default',
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
|
instanceKey: 'default',
|
||||||
|
currentScenario: null,
|
||||||
scenario: 'default',
|
scenario: 'default',
|
||||||
apiPort: 15003,
|
apiPort: 15003,
|
||||||
status: 'RUNNING' as const,
|
status: 'RUNNING' as const,
|
||||||
@@ -103,6 +105,8 @@ const buildCaller = (
|
|||||||
{
|
{
|
||||||
profileName: 'hwe:default',
|
profileName: 'hwe:default',
|
||||||
profile: 'hwe',
|
profile: 'hwe',
|
||||||
|
instanceKey: 'default',
|
||||||
|
currentScenario: null,
|
||||||
scenario: 'default',
|
scenario: 'default',
|
||||||
apiPort: 15015,
|
apiPort: 15015,
|
||||||
status: 'RUNNING' as const,
|
status: 'RUNNING' as const,
|
||||||
@@ -119,7 +123,7 @@ const buildCaller = (
|
|||||||
upsertProfile: async () => {
|
upsertProfile: async () => {
|
||||||
throw new Error('not used');
|
throw new Error('not used');
|
||||||
},
|
},
|
||||||
updateScenario: async () => null,
|
updateCurrentScenario: async () => null,
|
||||||
updateStatus: async () => null,
|
updateStatus: async () => null,
|
||||||
updateBuildStatus: async () => null,
|
updateBuildStatus: async () => null,
|
||||||
updateMeta: async () => null,
|
updateMeta: async () => null,
|
||||||
@@ -167,6 +171,8 @@ const buildCaller = (
|
|||||||
profileRows.map((profile) => ({
|
profileRows.map((profile) => ({
|
||||||
profileName: profile.profileName,
|
profileName: profile.profileName,
|
||||||
profile: profile.profile,
|
profile: profile.profile,
|
||||||
|
instanceKey: profile.instanceKey,
|
||||||
|
currentScenario: profile.currentScenario,
|
||||||
scenario: profile.scenario,
|
scenario: profile.scenario,
|
||||||
status: profile.status,
|
status: profile.status,
|
||||||
apiPort: profile.apiPort,
|
apiPort: profile.apiPort,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
|
|||||||
const profile: GatewayProfileRecord = {
|
const profile: GatewayProfileRecord = {
|
||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
|
instanceKey: '2',
|
||||||
|
currentScenario: '2',
|
||||||
scenario: '2',
|
scenario: '2',
|
||||||
apiPort: 15003,
|
apiPort: 15003,
|
||||||
status: 'STOPPED',
|
status: 'STOPPED',
|
||||||
@@ -58,7 +60,7 @@ const createHarness = (
|
|||||||
listProfiles: async () => [profile],
|
listProfiles: async () => [profile],
|
||||||
getProfile: async () => profile,
|
getProfile: async () => profile,
|
||||||
upsertProfile: async () => profile,
|
upsertProfile: async () => profile,
|
||||||
updateScenario: async () => profile,
|
updateCurrentScenario: async () => profile,
|
||||||
updateStatus: async (_profileName, status) => {
|
updateStatus: async (_profileName, status) => {
|
||||||
statuses.push(status);
|
statuses.push(status);
|
||||||
return { ...profile, status };
|
return { ...profile, status };
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository
|
|||||||
const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({
|
const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({
|
||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
|
instanceKey: '2',
|
||||||
|
currentScenario: '2',
|
||||||
scenario: '2',
|
scenario: '2',
|
||||||
apiPort: 15003,
|
apiPort: 15003,
|
||||||
status: 'RUNNING',
|
status: 'RUNNING',
|
||||||
@@ -172,6 +174,39 @@ describe('buildProcessDefinitions', () => {
|
|||||||
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the instance identity stable while passing the mutable current scenario', () => {
|
||||||
|
const definitions = buildProcessDefinitions(
|
||||||
|
{
|
||||||
|
...buildProfile(),
|
||||||
|
profileName: 'che:default',
|
||||||
|
instanceKey: 'default',
|
||||||
|
currentScenario: '1010',
|
||||||
|
scenario: '1010',
|
||||||
|
},
|
||||||
|
processConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(definitions.api.name).toBe('sammo:che:default:game-api');
|
||||||
|
expect(definitions.api.env).toMatchObject({
|
||||||
|
GAME_PROFILE_NAME: 'che:default',
|
||||||
|
SCENARIO: '1010',
|
||||||
|
});
|
||||||
|
expect(definitions.daemon.env).toMatchObject({
|
||||||
|
TURN_PROFILE_NAME: 'che:default',
|
||||||
|
SCENARIO: '1010',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the legacy default scenario marker only for an uninitialized instance runtime', () => {
|
||||||
|
const definitions = buildProcessDefinitions(
|
||||||
|
{ ...buildProfile(), currentScenario: null, scenario: 'default' },
|
||||||
|
processConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(definitions.api.env.SCENARIO).toBe('default');
|
||||||
|
expect(definitions.daemon.env.SCENARIO).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not forward PM2 identity or parent runtime roles to profile processes', () => {
|
it('does not forward PM2 identity or parent runtime roles to profile processes', () => {
|
||||||
const definitions = buildProcessDefinitions(buildProfile(), {
|
const definitions = buildProcessDefinitions(buildProfile(), {
|
||||||
...processConfig,
|
...processConfig,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const makeProfile = (
|
|||||||
): GatewayProfileRecord => ({
|
): GatewayProfileRecord => ({
|
||||||
profileName,
|
profileName,
|
||||||
profile: profileName.split(':')[0] ?? 'che',
|
profile: profileName.split(':')[0] ?? 'che',
|
||||||
|
instanceKey: profileName.split(':')[1] ?? 'default',
|
||||||
|
currentScenario: null,
|
||||||
scenario: profileName.split(':')[1] ?? 'default',
|
scenario: profileName.split(':')[1] ?? 'default',
|
||||||
apiPort: 15_003,
|
apiPort: 15_003,
|
||||||
status: 'RUNNING',
|
status: 'RUNNING',
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ describe('profile DEPLOY operation', () => {
|
|||||||
const profile: GatewayProfileRecord = {
|
const profile: GatewayProfileRecord = {
|
||||||
profileName: 'che:1010',
|
profileName: 'che:1010',
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
|
instanceKey: '1010',
|
||||||
|
currentScenario: '1010',
|
||||||
scenario: '1010',
|
scenario: '1010',
|
||||||
apiPort: 15003,
|
apiPort: 15003,
|
||||||
status: 'RUNNING',
|
status: 'RUNNING',
|
||||||
@@ -80,7 +82,7 @@ describe('profile DEPLOY operation', () => {
|
|||||||
listProfiles: async () => [profile],
|
listProfiles: async () => [profile],
|
||||||
getProfile: async () => profile,
|
getProfile: async () => profile,
|
||||||
upsertProfile: async () => profile,
|
upsertProfile: async () => profile,
|
||||||
updateScenario: async () => profile,
|
updateCurrentScenario: async () => profile,
|
||||||
updateStatus: async () => profile,
|
updateStatus: async () => profile,
|
||||||
updateBuildStatus: async () => profile,
|
updateBuildStatus: async () => profile,
|
||||||
updateMeta: async () => profile,
|
updateMeta: async () => profile,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildGatewayProfileName,
|
||||||
|
resolveGatewayProfileIdentity,
|
||||||
|
type GatewayProfileUpsertInput,
|
||||||
|
} from '../src/orchestrator/profileRepository.js';
|
||||||
|
|
||||||
|
const resolve = (input: Partial<GatewayProfileUpsertInput>) =>
|
||||||
|
resolveGatewayProfileIdentity({
|
||||||
|
profile: 'che',
|
||||||
|
apiPort: 15003,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Gateway profile identity', () => {
|
||||||
|
it('builds the immutable technical id from profile and instance key', () => {
|
||||||
|
expect(buildGatewayProfileName('che', 'default')).toBe('che:default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat a new default instance as an initialized scenario', () => {
|
||||||
|
expect(resolve({ instanceKey: 'default' })).toEqual({
|
||||||
|
instanceKey: 'default',
|
||||||
|
currentScenario: null,
|
||||||
|
shouldUpdateCurrentScenario: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the old bootstrap default marker without clearing an existing scenario on upsert', () => {
|
||||||
|
expect(resolve({ scenario: 'default' })).toEqual({
|
||||||
|
instanceKey: 'default',
|
||||||
|
currentScenario: null,
|
||||||
|
shouldUpdateCurrentScenario: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a legacy non-default scenario to both identity and current state', () => {
|
||||||
|
expect(resolve({ scenario: '2' })).toEqual({
|
||||||
|
instanceKey: '2',
|
||||||
|
currentScenario: '2',
|
||||||
|
shouldUpdateCurrentScenario: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a default instance stable when its current scenario changes', () => {
|
||||||
|
expect(resolve({ instanceKey: 'default', currentScenario: '1010' })).toEqual({
|
||||||
|
instanceKey: 'default',
|
||||||
|
currentScenario: '1010',
|
||||||
|
shouldUpdateCurrentScenario: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,25 +6,25 @@ describe('orderGatewayProfiles', () => {
|
|||||||
it('uses the public server order instead of alphabetical profile order', () => {
|
it('uses the public server order instead of alphabetical profile order', () => {
|
||||||
const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({
|
const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({
|
||||||
profile,
|
profile,
|
||||||
scenario: 'default',
|
instanceKey: 'default',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(orderGatewayProfiles(profiles).map(({ profile }) => profile)).toEqual(GATEWAY_PROFILE_ORDER);
|
expect(orderGatewayProfiles(profiles).map(({ profile }) => profile)).toEqual(GATEWAY_PROFILE_ORDER);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('orders scenarios within a profile and places unknown profiles afterward', () => {
|
it('orders instance keys within a profile and places unknown profiles afterward', () => {
|
||||||
const profiles = [
|
const profiles = [
|
||||||
{ profile: 'zeta', scenario: 'default' },
|
{ profile: 'zeta', instanceKey: 'default' },
|
||||||
{ profile: 'che', scenario: '20' },
|
{ profile: 'che', instanceKey: '20' },
|
||||||
{ profile: 'alpha', scenario: 'default' },
|
{ profile: 'alpha', instanceKey: 'default' },
|
||||||
{ profile: 'che', scenario: '10' },
|
{ profile: 'che', instanceKey: '10' },
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(orderGatewayProfiles(profiles)).toEqual([
|
expect(orderGatewayProfiles(profiles)).toEqual([
|
||||||
{ profile: 'che', scenario: '10' },
|
{ profile: 'che', instanceKey: '10' },
|
||||||
{ profile: 'che', scenario: '20' },
|
{ profile: 'che', instanceKey: '20' },
|
||||||
{ profile: 'alpha', scenario: 'default' },
|
{ profile: 'alpha', instanceKey: 'default' },
|
||||||
{ profile: 'zeta', scenario: 'default' },
|
{ profile: 'zeta', instanceKey: 'default' },
|
||||||
]);
|
]);
|
||||||
expect(profiles[0]?.profile).toBe('zeta');
|
expect(profiles[0]?.profile).toBe('zeta');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
|
|||||||
|
|
||||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||||
gatewaySchemaHead: '20260811000000_add_gateway_operation_logs',
|
gatewaySchemaHead: '20260813000000_split_gateway_profile_identity',
|
||||||
gameSchemaHead: '20260803000000_add_logical_game_clock',
|
gameSchemaHead: '20260803000000_add_logical_game_clock',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ const installFixture = async (
|
|||||||
{
|
{
|
||||||
profileName: 'hwe:default',
|
profileName: 'hwe:default',
|
||||||
profile: 'hwe',
|
profile: 'hwe',
|
||||||
|
instanceKey: 'default',
|
||||||
|
currentScenario: '1010',
|
||||||
meta: {},
|
meta: {},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -160,6 +162,8 @@ const installFixture = async (
|
|||||||
{
|
{
|
||||||
profileName: 'hwe:default',
|
profileName: 'hwe:default',
|
||||||
profile: 'hwe',
|
profile: 'hwe',
|
||||||
|
instanceKey: 'default',
|
||||||
|
currentScenario: '1010',
|
||||||
scenario: '1010',
|
scenario: '1010',
|
||||||
apiPort: 15015,
|
apiPort: 15015,
|
||||||
status: 'RUNNING',
|
status: 'RUNNING',
|
||||||
@@ -352,7 +356,9 @@ test('directs profile deployment to the selected server version tab', async ({ p
|
|||||||
await expect(versionTab).toBeFocused();
|
await expect(versionTab).toBeFocused();
|
||||||
const tabAndHeaderGeometry = await Promise.all([
|
const tabAndHeaderGeometry = await Promise.all([
|
||||||
tabs.evaluate((element) => element.getBoundingClientRect().top),
|
tabs.evaluate((element) => element.getBoundingClientRect().top),
|
||||||
page.getByText('hwe:default (hwe)', { exact: true }).evaluate((element) => element.getBoundingClientRect().top),
|
page
|
||||||
|
.getByText('서버 ID: hwe:default · 인스턴스: default', { exact: true })
|
||||||
|
.evaluate((element) => element.getBoundingClientRect().top),
|
||||||
]);
|
]);
|
||||||
expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]);
|
expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]);
|
||||||
await page.screenshot({ path: testInfo.outputPath('status-tabs-desktop.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('status-tabs-desktop.png'), fullPage: true });
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
|
|||||||
{
|
{
|
||||||
profileName: 'hwe:2',
|
profileName: 'hwe:2',
|
||||||
profile: 'hwe',
|
profile: 'hwe',
|
||||||
|
instanceKey: '2',
|
||||||
|
currentScenario: '1010',
|
||||||
scenario: '1010',
|
scenario: '1010',
|
||||||
status: 'RUNNING',
|
status: 'RUNNING',
|
||||||
buildStatus: 'SUCCEEDED',
|
buildStatus: 'SUCCEEDED',
|
||||||
@@ -59,6 +61,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
|
|||||||
{
|
{
|
||||||
profileName: 'hwe:2',
|
profileName: 'hwe:2',
|
||||||
profile: 'hwe',
|
profile: 'hwe',
|
||||||
|
instanceKey: '2',
|
||||||
|
currentScenario: '1010',
|
||||||
meta: { korName: '환상서버' },
|
meta: { korName: '환상서버' },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -209,7 +213,7 @@ test('scoped administrators see the same navigation while ordinary users do not'
|
|||||||
await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible();
|
await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible();
|
||||||
await scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
|
await scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
|
||||||
const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' });
|
const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' });
|
||||||
await expect(scopedNavigation.getByRole('link', { name: '환상서버 (hwe:2)' })).toBeVisible();
|
await expect(scopedNavigation.getByRole('link', { name: '환상서버 [2]' })).toBeVisible();
|
||||||
await expect(scopedNavigation.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
|
await expect(scopedNavigation.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
|
||||||
await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0);
|
await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0);
|
||||||
await scopedContext.close();
|
await scopedContext.close();
|
||||||
|
|||||||
@@ -55,8 +55,10 @@ type FixtureState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
|
const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
|
||||||
profileName: 'che:2',
|
profileName: 'che:default',
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
|
instanceKey: 'default',
|
||||||
|
currentScenario: '2',
|
||||||
scenario: '2',
|
scenario: '2',
|
||||||
apiPort: 15003,
|
apiPort: 15003,
|
||||||
status: runtimeRunning ? 'RUNNING' : 'STOPPED',
|
status: runtimeRunning ? 'RUNNING' : 'STOPPED',
|
||||||
@@ -69,7 +71,7 @@ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown
|
|||||||
activeOperation: null,
|
activeOperation: null,
|
||||||
runtimeActions: [],
|
runtimeActions: [],
|
||||||
runtime: {
|
runtime: {
|
||||||
profileName: 'che:2',
|
profileName: 'che:default',
|
||||||
frontendRunning: runtimeRunning,
|
frontendRunning: runtimeRunning,
|
||||||
apiRunning: runtimeRunning,
|
apiRunning: runtimeRunning,
|
||||||
daemonRunning: runtimeRunning,
|
daemonRunning: runtimeRunning,
|
||||||
@@ -145,10 +147,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
await route.abort('failed');
|
await route.abort('failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (names.includes('admin.releases.gatewayState') && (state.gatewayStateFailuresRemaining ?? 0) > 0) {
|
||||||
names.includes('admin.releases.gatewayState') &&
|
|
||||||
(state.gatewayStateFailuresRemaining ?? 0) > 0
|
|
||||||
) {
|
|
||||||
state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1;
|
state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1;
|
||||||
state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1;
|
state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1;
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
@@ -171,8 +170,10 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
if (name === 'admin.profiles.listNavigation') {
|
if (name === 'admin.profiles.listNavigation') {
|
||||||
return response([
|
return response([
|
||||||
{
|
{
|
||||||
profileName: 'che:2',
|
profileName: 'che:default',
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
|
instanceKey: 'default',
|
||||||
|
currentScenario: '2',
|
||||||
meta: { korName: '천하서버' },
|
meta: { korName: '천하서버' },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -314,7 +315,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
if (name === 'admin.operations.requestReset') {
|
if (name === 'admin.operations.requestReset') {
|
||||||
const operation: Operation = {
|
const operation: Operation = {
|
||||||
id: '11111111-1111-4111-8111-111111111111',
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
profileName: 'che:2',
|
profileName: 'che:default',
|
||||||
type: 'RESET',
|
type: 'RESET',
|
||||||
status: 'QUEUED',
|
status: 'QUEUED',
|
||||||
sourceMode: 'COMMIT',
|
sourceMode: 'COMMIT',
|
||||||
@@ -330,7 +331,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
if (name === 'admin.operations.requestDeploy') {
|
if (name === 'admin.operations.requestDeploy') {
|
||||||
const operation: Operation = {
|
const operation: Operation = {
|
||||||
id: '66666666-6666-4666-8666-666666666666',
|
id: '66666666-6666-4666-8666-666666666666',
|
||||||
profileName: 'che:2',
|
profileName: 'che:default',
|
||||||
type: 'DEPLOY',
|
type: 'DEPLOY',
|
||||||
status: 'QUEUED',
|
status: 'QUEUED',
|
||||||
sourceMode: 'BRANCH',
|
sourceMode: 'BRANCH',
|
||||||
@@ -368,7 +369,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
type === 'START'
|
type === 'START'
|
||||||
? '22222222-2222-4222-8222-222222222222'
|
? '22222222-2222-4222-8222-222222222222'
|
||||||
: '33333333-3333-4333-8333-333333333333',
|
: '33333333-3333-4333-8333-333333333333',
|
||||||
profileName: 'che:2',
|
profileName: 'che:default',
|
||||||
type,
|
type,
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
payload: {},
|
payload: {},
|
||||||
@@ -382,7 +383,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
if (name === 'admin.operations.retry') {
|
if (name === 'admin.operations.retry') {
|
||||||
const operation: Operation = {
|
const operation: Operation = {
|
||||||
id: '44444444-4444-4444-8444-444444444444',
|
id: '44444444-4444-4444-8444-444444444444',
|
||||||
profileName: 'che:2',
|
profileName: 'che:default',
|
||||||
type: 'RESET',
|
type: 'RESET',
|
||||||
status: 'QUEUED',
|
status: 'QUEUED',
|
||||||
sourceMode: 'COMMIT',
|
sourceMode: 'COMMIT',
|
||||||
@@ -420,9 +421,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
await page.goto('admin/servers/che%3A2/scenario');
|
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||||
await expect(page.getByTestId('server-operations-page')).toBeVisible();
|
await expect(page.getByTestId('server-operations-page')).toBeVisible();
|
||||||
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/);
|
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/scenario$/);
|
||||||
await expect(page.getByTestId('source-current')).toBeChecked();
|
await expect(page.getByTestId('source-current')).toBeChecked();
|
||||||
await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋');
|
await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋');
|
||||||
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
|
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
|
||||||
@@ -490,13 +491,14 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
|
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
|
||||||
await page.getByTestId('load-scenarios').click();
|
await page.getByTestId('load-scenarios').click();
|
||||||
await page.getByTestId('scenario-select').selectOption('5');
|
await page.getByTestId('scenario-select').selectOption('5');
|
||||||
|
await page.getByLabel('작업 예약 (서버 시간 UTC+9)').fill('2026-08-13T09:30');
|
||||||
await page.getByTestId('request-reset').hover();
|
await page.getByTestId('request-reset').hover();
|
||||||
await page.getByTestId('request-reset').click();
|
await page.getByTestId('request-reset').click();
|
||||||
|
|
||||||
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
|
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
|
||||||
await expect(page.getByTestId('operations-table')).toContainText('RESET');
|
await expect(page.getByTestId('operations-table')).toContainText('RESET');
|
||||||
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
||||||
await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.');
|
await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.');
|
||||||
await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.');
|
await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.');
|
||||||
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
|
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
|
||||||
const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
|
const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
|
||||||
@@ -544,6 +546,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
|
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
||||||
|
expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2026-08-13T00:30:00.000Z"');
|
||||||
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
@@ -588,9 +591,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
mobileOperationTableGeometry.scrollerWidth
|
mobileOperationTableGeometry.scrollerWidth
|
||||||
);
|
);
|
||||||
expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0);
|
expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0);
|
||||||
expect(
|
expect(mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth).toBeLessThanOrEqual(
|
||||||
mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth
|
mobileOperationTableGeometry.viewportWidth
|
||||||
).toBeLessThanOrEqual(mobileOperationTableGeometry.viewportWidth);
|
);
|
||||||
expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual(
|
expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual(
|
||||||
mobileOperationTableGeometry.viewportWidth
|
mobileOperationTableGeometry.viewportWidth
|
||||||
);
|
);
|
||||||
@@ -612,7 +615,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
|||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
await page.goto('admin/servers/che%3A2/version');
|
await page.goto('admin/servers/che%3Adefault/version');
|
||||||
await expect(page.getByRole('heading', { name: 'DB 보존 버전 업데이트' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'DB 보존 버전 업데이트' })).toBeVisible();
|
||||||
await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
|
await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByRole('link', { name: '버전 업데이트', exact: true })).toHaveAttribute(
|
await expect(page.getByRole('link', { name: '버전 업데이트', exact: true })).toHaveAttribute(
|
||||||
@@ -624,7 +627,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
|||||||
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
|
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
|
||||||
await expect(page.getByTestId('operations-table')).toContainText('DEPLOY');
|
await expect(page.getByTestId('operations-table')).toContainText('DEPLOY');
|
||||||
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
||||||
await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.');
|
await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.');
|
||||||
await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete');
|
await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete');
|
||||||
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
|
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
|
||||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true);
|
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true);
|
||||||
@@ -653,7 +656,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn
|
|||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
await page.goto('admin/servers/che%3A2/scenario');
|
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||||
await expect(page.getByTestId('reset-turn-term')).toHaveValue('20');
|
await expect(page.getByTestId('reset-turn-term')).toHaveValue('20');
|
||||||
await page.getByText('고급 시나리오 옵션').click();
|
await page.getByText('고급 시나리오 옵션').click();
|
||||||
await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타');
|
await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타');
|
||||||
@@ -677,7 +680,7 @@ test('edits server reset defaults through profile metadata settings', async ({ p
|
|||||||
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
|
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
|
|
||||||
await page.goto('admin/servers/che%3A2');
|
await page.goto('admin/servers/che%3Adefault');
|
||||||
await page.getByText('서버 리셋 기본 옵션').click();
|
await page.getByText('서버 리셋 기본 옵션').click();
|
||||||
await page.getByTestId('meta-reset-turn-term').selectOption('10');
|
await page.getByTestId('meta-reset-turn-term').selectOption('10');
|
||||||
await page.getByTestId('meta-reset-npc-mode').selectOption('2');
|
await page.getByTestId('meta-reset-npc-mode').selectOption('2');
|
||||||
@@ -740,7 +743,7 @@ test('shows a dismissible error toast when profile metadata persistence fails',
|
|||||||
};
|
};
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
|
|
||||||
await page.goto('admin/servers/che%3A2');
|
await page.goto('admin/servers/che%3Adefault');
|
||||||
await page.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error');
|
await page.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error');
|
||||||
await page.getByRole('button', { name: '메타 저장' }).click();
|
await page.getByRole('button', { name: '메타 저장' }).click();
|
||||||
|
|
||||||
@@ -763,7 +766,7 @@ test('renders the fixed-profile version form without waiting for the server list
|
|||||||
};
|
};
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
|
|
||||||
await page.goto('admin/servers/che%3A2/version');
|
await page.goto('admin/servers/che%3Adefault/version');
|
||||||
await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 });
|
await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 });
|
||||||
expect(state.profileNavigationResolved).toBe(false);
|
expect(state.profileNavigationResolved).toBe(false);
|
||||||
await expect.poll(() => state.profileNavigationResolved).toBe(true);
|
await expect.poll(() => state.profileNavigationResolved).toBe(true);
|
||||||
@@ -780,7 +783,7 @@ test('recovers the current-version scenario catalog after the initial request fa
|
|||||||
};
|
};
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
|
|
||||||
await page.goto('admin/servers/che%3A2/scenario');
|
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||||
await expect(page.getByTestId('scenario-select')).toContainText('선택할 수 있는 시나리오가 없습니다.');
|
await expect(page.getByTestId('scenario-select')).toContainText('선택할 수 있는 시나리오가 없습니다.');
|
||||||
await expect(page.getByTestId('request-reset')).toBeDisabled();
|
await expect(page.getByTestId('request-reset')).toBeDisabled();
|
||||||
await page.getByTestId('load-scenarios').click();
|
await page.getByTestId('load-scenarios').click();
|
||||||
@@ -788,7 +791,9 @@ test('recovers the current-version scenario catalog after the initial request fa
|
|||||||
await expect(page.getByTestId('request-reset')).toBeEnabled();
|
await expect(page.getByTestId('request-reset')).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders the server navigation before the detailed runtime profile request resolves', async ({ page }) => {
|
test('renders the stable server identity without exposing the default suffix as the display name', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
const state: FixtureState = {
|
const state: FixtureState = {
|
||||||
operations: [],
|
operations: [],
|
||||||
gatewayOperations: [],
|
gatewayOperations: [],
|
||||||
@@ -798,10 +803,42 @@ test('renders the server navigation before the detailed runtime profile request
|
|||||||
};
|
};
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
|
|
||||||
await page.goto('admin/servers/che%3A2');
|
await page.goto('admin/servers/che%3Adefault');
|
||||||
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' });
|
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' });
|
||||||
await expect(navigation.getByRole('link', { name: '천하서버 (che:2)' })).toBeVisible({ timeout: 900 });
|
const profileLink = navigation.getByRole('link', { name: '천하서버' });
|
||||||
|
await expect(profileLink).toBeVisible({ timeout: 900 });
|
||||||
|
await expect(profileLink).toHaveAttribute('title', '서버 ID: che:default');
|
||||||
|
await expect(navigation).not.toContainText('천하서버 (che:default)');
|
||||||
await expect(navigation.getByRole('link', { name: 'Gateway 릴리스' })).toBeVisible({ timeout: 900 });
|
await expect(navigation.getByRole('link', { name: 'Gateway 릴리스' })).toBeVisible({ timeout: 900 });
|
||||||
|
await expect(page.getByText('서버 ID: che:default · 인스턴스: default')).toBeVisible();
|
||||||
|
await expect(page.getByText('현재 시나리오: 2')).toBeVisible();
|
||||||
|
await profileLink.focus();
|
||||||
|
const desktop = await profileLink.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
x: rect.x,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
overflow: element.scrollWidth - element.clientWidth,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
color: style.color,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(desktop.width).toBeGreaterThan(100);
|
||||||
|
expect(desktop.height).toBeGreaterThan(30);
|
||||||
|
expect(desktop.overflow).toBeLessThanOrEqual(0);
|
||||||
|
expect(desktop.backgroundColor).toBe('rgb(45, 27, 8)');
|
||||||
|
expect(desktop.color).toBe('rgb(253, 230, 138)');
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('profile-identity-desktop.png'), fullPage: true });
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.getByRole('button', { name: '관리자 메뉴' }).click();
|
||||||
|
await expect(profileLink).toBeVisible();
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||||
|
).toBeLessThanOrEqual(0);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('profile-identity-mobile.png'), fullPage: true });
|
||||||
expect(state.profileNavigationRequests).toBe(1);
|
expect(state.profileNavigationRequests).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -811,12 +848,12 @@ test('scenario-only operator resets the current version without Git or Gateway c
|
|||||||
gatewayOperations: [],
|
gatewayOperations: [],
|
||||||
runtimeRunning: true,
|
runtimeRunning: true,
|
||||||
requestBodies: [],
|
requestBodies: [],
|
||||||
capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:2'] }],
|
capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:default'] }],
|
||||||
};
|
};
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
await page.goto('admin/servers/che%3A2/scenario');
|
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||||
await expect(page.getByTestId('source-current')).toBeChecked();
|
await expect(page.getByTestId('source-current')).toBeChecked();
|
||||||
await expect(page.getByTestId('source-branch')).toHaveCount(0);
|
await expect(page.getByTestId('source-branch')).toHaveCount(0);
|
||||||
await expect(page.getByTestId('source-commit')).toHaveCount(0);
|
await expect(page.getByTestId('source-commit')).toHaveCount(0);
|
||||||
@@ -994,9 +1031,7 @@ test('moves long Gateway release errors out of the table column into an expandab
|
|||||||
expect(mobileGeometry.detailWidth).toBeGreaterThanOrEqual(mobileGeometry.tableWidth - 1);
|
expect(mobileGeometry.detailWidth).toBeGreaterThanOrEqual(mobileGeometry.tableWidth - 1);
|
||||||
expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1);
|
expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1);
|
||||||
expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0);
|
expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0);
|
||||||
expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual(
|
expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
||||||
mobileGeometry.viewportWidth
|
|
||||||
);
|
|
||||||
expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
||||||
await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-mobile.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-mobile.png'), fullPage: true });
|
||||||
|
|
||||||
@@ -1041,7 +1076,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
|||||||
operations: [
|
operations: [
|
||||||
{
|
{
|
||||||
id: '55555555-5555-4555-8555-555555555555',
|
id: '55555555-5555-4555-8555-555555555555',
|
||||||
profileName: 'che:2',
|
profileName: 'che:default',
|
||||||
type: 'RESET',
|
type: 'RESET',
|
||||||
status: 'FAILED',
|
status: 'FAILED',
|
||||||
sourceMode: 'COMMIT',
|
sourceMode: 'COMMIT',
|
||||||
@@ -1062,7 +1097,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
|||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
await page.goto('admin/servers/che%3A2/scenario');
|
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||||
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
|
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
|
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
|
||||||
const failure = page.getByTestId('operations-table').getByText(longError);
|
const failure = page.getByTestId('operations-table').getByText(longError);
|
||||||
|
|||||||
@@ -16,12 +16,28 @@ const adminNavigationClient = directTrpc.admin as unknown as {
|
|||||||
capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } };
|
capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } };
|
||||||
profiles: {
|
profiles: {
|
||||||
listNavigation: {
|
listNavigation: {
|
||||||
query: () => Promise<Array<{ profileName: string; profile: string; meta?: Record<string, unknown> }>>;
|
query: () => Promise<
|
||||||
|
Array<{
|
||||||
|
profileName: string;
|
||||||
|
profile: string;
|
||||||
|
instanceKey: string;
|
||||||
|
currentScenario: string | null;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
|
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
|
||||||
const profiles = ref<Array<{ profileName: string; profile: string; meta?: Record<string, unknown> }>>([]);
|
const profiles = ref<
|
||||||
|
Array<{
|
||||||
|
profileName: string;
|
||||||
|
profile: string;
|
||||||
|
instanceKey: string;
|
||||||
|
currentScenario: string | null;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
|
|
||||||
const isRootAdmin = computed(() =>
|
const isRootAdmin = computed(() =>
|
||||||
(auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser')
|
(auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser')
|
||||||
@@ -38,7 +54,8 @@ const hasAnyProfileCapability = computed(() =>
|
|||||||
|
|
||||||
const profileLabel = (profile: (typeof profiles.value)[number]): string => {
|
const profileLabel = (profile: (typeof profiles.value)[number]): string => {
|
||||||
const korName = profile.meta?.korName;
|
const korName = profile.meta?.korName;
|
||||||
return typeof korName === 'string' && korName.trim() ? `${korName} (${profile.profileName})` : profile.profileName;
|
const displayName = typeof korName === 'string' && korName.trim() ? korName.trim() : profile.profile;
|
||||||
|
return profile.instanceKey === 'default' ? displayName : `${displayName} [${profile.instanceKey}]`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigation = computed(() => [
|
const navigation = computed(() => [
|
||||||
@@ -70,6 +87,7 @@ const navigation = computed(() => [
|
|||||||
...profiles.value.map((profile) => ({
|
...profiles.value.map((profile) => ({
|
||||||
to: `/admin/servers/${encodeURIComponent(profile.profileName)}`,
|
to: `/admin/servers/${encodeURIComponent(profile.profileName)}`,
|
||||||
label: profileLabel(profile),
|
label: profileLabel(profile),
|
||||||
|
title: `서버 ID: ${profile.profileName}`,
|
||||||
icon: '└',
|
icon: '└',
|
||||||
exact: false,
|
exact: false,
|
||||||
visible: true,
|
visible: true,
|
||||||
@@ -156,6 +174,7 @@ onMounted(async () => {
|
|||||||
:class="{ child: item.child }"
|
:class="{ child: item.child }"
|
||||||
:active-class="item.exact ? '' : 'active'"
|
:active-class="item.exact ? '' : 'active'"
|
||||||
:exact-active-class="item.exact ? 'active' : ''"
|
:exact-active-class="item.exact ? 'active' : ''"
|
||||||
|
:title="'title' in item ? item.title : undefined"
|
||||||
@click="menuOpen = false"
|
@click="menuOpen = false"
|
||||||
>
|
>
|
||||||
<span class="admin-nav-icon" aria-hidden="true">{{ item.icon }}</span>
|
<span class="admin-nav-icon" aria-hidden="true">{{ item.icon }}</span>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ const scheduleDeletion = async (): Promise<void> => {
|
|||||||
currentCredential,
|
currentCredential,
|
||||||
});
|
});
|
||||||
window.localStorage.removeItem('sammo-session-token');
|
window.localStorage.removeItem('sammo-session-token');
|
||||||
successMessage.value = `${new Date(result.deleteAfter).toLocaleDateString('ko-KR')}까지 정보가 보존됩니다.`;
|
successMessage.value = `${formatServerDateTime(result.deleteAfter, { format: 'date' })}까지 정보가 보존됩니다.`;
|
||||||
await router.replace('/');
|
await router.replace('/');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -411,7 +412,7 @@ onBeforeUnmount(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="legacy-bg1">가입일시</th>
|
<th class="legacy-bg1">가입일시</th>
|
||||||
<td colspan="2">{{ new Date(account.createdAt).toLocaleString('ko-KR') }}</td>
|
<td colspan="2">{{ formatServerDateTime(account.createdAt) }}</td>
|
||||||
<td colspan="3">
|
<td colspan="3">
|
||||||
개인정보 3자 제공 동의 : {{ account.thirdPartyUse ? '○' : '×' }}
|
개인정보 3자 제공 동의 : {{ account.thirdPartyUse ? '○' : '×' }}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime, serverDateTimeInputToIso, toServerDateTimeInputValue } from '@sammo-ts/common';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
||||||
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
||||||
@@ -175,6 +176,9 @@ type AdminPublicUser = {
|
|||||||
type AdminProfile = {
|
type AdminProfile = {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
|
instanceKey: string;
|
||||||
|
currentScenario: string | null;
|
||||||
|
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
||||||
scenario: string;
|
scenario: string;
|
||||||
status: string;
|
status: string;
|
||||||
apiPort: number;
|
apiPort: number;
|
||||||
@@ -435,8 +439,7 @@ const runtimeActionStatusClass = (status: AdminProfile['runtimeActions'][number]
|
|||||||
const isRuntimeActionTerminal = (status: AdminProfile['runtimeActions'][number]['status']): boolean =>
|
const isRuntimeActionTerminal = (status: AdminProfile['runtimeActions'][number]['status']): boolean =>
|
||||||
status === 'APPLIED' || status === 'FAILED' || status === 'IGNORED';
|
status === 'APPLIED' || status === 'FAILED' || status === 'IGNORED';
|
||||||
|
|
||||||
const formatRuntimeActionTime = (value: string | null): string =>
|
const formatRuntimeActionTime = (value: string | null): string => formatServerDateTime(value);
|
||||||
value ? new Date(value).toLocaleString('ko-KR') : '';
|
|
||||||
|
|
||||||
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
|
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
|
||||||
const userLookupValue = ref('');
|
const userLookupValue = ref('');
|
||||||
@@ -630,14 +633,7 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toLocalInputValue = (value: string): string => {
|
const toLocalInputValue = (value: string): string => toServerDateTimeInputValue(value);
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return '';
|
|
||||||
const pad = (part: number): string => String(part).padStart(2, '0');
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(
|
|
||||||
date.getMinutes()
|
|
||||||
)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadProfiles = async () => {
|
const loadProfiles = async () => {
|
||||||
profilesLoading.value = true;
|
profilesLoading.value = true;
|
||||||
@@ -795,7 +791,7 @@ const requestProfileAction = async (profileName: string, action: AdminAction) =>
|
|||||||
const durationValue = durationMinutes && validDuration(profileName) ? durationMinutes : undefined;
|
const durationValue = durationMinutes && validDuration(profileName) ? durationMinutes : undefined;
|
||||||
const scheduledAt =
|
const scheduledAt =
|
||||||
action === 'RESET_SCHEDULED' && actionState?.scheduledAt
|
action === 'RESET_SCHEDULED' && actionState?.scheduledAt
|
||||||
? new Date(actionState.scheduledAt).toISOString()
|
? serverDateTimeInputToIso(actionState.scheduledAt)
|
||||||
: undefined;
|
: undefined;
|
||||||
const reason = actionState?.reason.trim() || undefined;
|
const reason = actionState?.reason.trim() || undefined;
|
||||||
let runtimeActionId: string | undefined;
|
let runtimeActionId: string | undefined;
|
||||||
@@ -952,7 +948,7 @@ const updateKakaoGrace = async (clear = false) => {
|
|||||||
try {
|
try {
|
||||||
const result = await adminClient.users.updateKakaoGrace.mutate({
|
const result = await adminClient.users.updateKakaoGrace.mutate({
|
||||||
userId: userResult.value.id,
|
userId: userResult.value.id,
|
||||||
until: clear || !kakaoGraceUntil.value ? null : new Date(kakaoGraceUntil.value).toISOString(),
|
until: clear || !kakaoGraceUntil.value ? null : (serverDateTimeInputToIso(kakaoGraceUntil.value) ?? null),
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
userResult.value = {
|
userResult.value = {
|
||||||
@@ -983,7 +979,9 @@ const grantSpecialAccess = async () => {
|
|||||||
.map((profile) => profile.trim())
|
.map((profile) => profile.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
|
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
|
||||||
expiresAt: specialAccessExpiresAt.value ? new Date(specialAccessExpiresAt.value).toISOString() : null,
|
expiresAt: specialAccessExpiresAt.value
|
||||||
|
? (serverDateTimeInputToIso(specialAccessExpiresAt.value) ?? null)
|
||||||
|
: null,
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
|
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
|
||||||
@@ -1071,7 +1069,7 @@ const applyBan = async () => {
|
|||||||
}
|
}
|
||||||
const reason = requireUserActionReason();
|
const reason = requireUserActionReason();
|
||||||
if (!reason) return;
|
if (!reason) return;
|
||||||
const until = banUntil.value ? new Date(banUntil.value).toISOString() : null;
|
const until = banUntil.value ? (serverDateTimeInputToIso(banUntil.value) ?? null) : null;
|
||||||
const patch = {
|
const patch = {
|
||||||
bannedUntil: until,
|
bannedUntil: until,
|
||||||
notes: banReason.value.trim() || undefined,
|
notes: banReason.value.trim() || undefined,
|
||||||
@@ -1150,7 +1148,7 @@ const applyRestriction = async () => {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const restriction = {
|
const restriction = {
|
||||||
blockedFeatures: features.length ? features : undefined,
|
blockedFeatures: features.length ? features : undefined,
|
||||||
until: restrictionUntil.value ? new Date(restrictionUntil.value).toISOString() : undefined,
|
until: restrictionUntil.value ? serverDateTimeInputToIso(restrictionUntil.value) : undefined,
|
||||||
reason: restrictionReason.value.trim() || undefined,
|
reason: restrictionReason.value.trim() || undefined,
|
||||||
notes: restrictionNotes.value.trim() || undefined,
|
notes: restrictionNotes.value.trim() || undefined,
|
||||||
};
|
};
|
||||||
@@ -1213,7 +1211,7 @@ const scheduleDeleteUser = async () => {
|
|||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
userResult.value = { ...userResult.value, deleteAfter: result.deleteAfter };
|
userResult.value = { ...userResult.value, deleteAfter: result.deleteAfter };
|
||||||
forceDeleteStatus.value = `탈퇴 예약 완료: ${new Date(result.deleteAfter).toLocaleString('ko-KR')}`;
|
forceDeleteStatus.value = `탈퇴 예약 완료: ${formatServerDateTime(result.deleteAfter)}`;
|
||||||
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
|
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
forceDeleteStatus.value = '탈퇴 예약 실패';
|
forceDeleteStatus.value = '탈퇴 예약 실패';
|
||||||
@@ -1357,7 +1355,7 @@ onMounted(() => {
|
|||||||
<span class="block truncate">{{ user.email || '이메일 없음' }}</span>
|
<span class="block truncate">{{ user.email || '이메일 없음' }}</span>
|
||||||
<span
|
<span
|
||||||
>{{ user.oauthType }} ·
|
>{{ user.oauthType }} ·
|
||||||
{{ new Date(user.createdAt).toLocaleDateString('ko-KR') }}</span
|
{{ formatServerDateTime(user.createdAt, { format: 'date' }) }}</span
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<span class="flex flex-wrap gap-1 md:justify-end">
|
<span class="flex flex-wrap gap-1 md:justify-end">
|
||||||
@@ -1436,15 +1434,17 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-500">
|
<div class="text-xs text-zinc-500">
|
||||||
Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작:
|
Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작:
|
||||||
{{ new Date(userResult.kakaoGraceStartedAt).toLocaleString('ko-KR') }}
|
{{ formatServerDateTime(userResult.kakaoGraceStartedAt) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
|
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
|
||||||
관리자 유예: {{ new Date(userResult.kakaoGraceUntil).toLocaleString('ko-KR') }}까지
|
관리자 유예: {{ formatServerDateTime(userResult.kakaoGraceUntil) }}까지
|
||||||
</div>
|
</div>
|
||||||
<div v-if="userResult.deleteAfter" class="text-xs text-red-300">
|
<div v-if="userResult.deleteAfter" class="text-xs text-red-300">
|
||||||
탈퇴 예약: {{ new Date(userResult.deleteAfter).toLocaleString('ko-KR') }}
|
탈퇴 예약: {{ formatServerDateTime(userResult.deleteAfter) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">
|
||||||
|
가입일: {{ formatServerDateTime(userResult.createdAt) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-500">가입일: {{ userResult.createdAt }}</div>
|
|
||||||
<div class="text-xs text-zinc-400 mt-2">제재 상태</div>
|
<div class="text-xs text-zinc-400 mt-2">제재 상태</div>
|
||||||
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap"
|
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap"
|
||||||
>{{ JSON.stringify(userResult.sanctions, null, 2) }}
|
>{{ JSON.stringify(userResult.sanctions, null, 2) }}
|
||||||
@@ -1632,7 +1632,8 @@ onMounted(() => {
|
|||||||
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
|
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
|
||||||
<div class="text-xs text-zinc-400">
|
<div class="text-xs text-zinc-400">
|
||||||
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와
|
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와
|
||||||
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다.
|
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다. 시각 입력은 서버 시간
|
||||||
|
UTC+9 기준입니다.
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<select
|
<select
|
||||||
@@ -1696,11 +1697,11 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료
|
장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료
|
||||||
{{ grant.expiresAt ? new Date(grant.expiresAt).toLocaleString('ko-KR') : '없음' }}
|
{{ formatServerDateTime(grant.expiresAt, { fallback: '없음' }) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-zinc-500">부여 사유: {{ grant.reason }}</div>
|
<div class="text-zinc-500">부여 사유: {{ grant.reason }}</div>
|
||||||
<div v-if="grant.revokedAt" class="text-red-300">
|
<div v-if="grant.revokedAt" class="text-red-300">
|
||||||
해제됨: {{ new Date(grant.revokedAt).toLocaleString('ko-KR') }} ·
|
해제됨: {{ formatServerDateTime(grant.revokedAt) }} ·
|
||||||
{{ grant.revokedReason }}
|
{{ grant.revokedReason }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1713,7 +1714,8 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<h4 class="text-base font-semibold">Kakao 인증 유예</h4>
|
<h4 class="text-base font-semibold">Kakao 인증 유예</h4>
|
||||||
<div class="text-xs text-zinc-500">
|
<div class="text-xs text-zinc-500">
|
||||||
기본·서버별 유예가 끝난 사용자를 예외적으로 더 허용할 때 사용합니다.
|
기본·서버별 유예가 끝난 사용자를 예외적으로 더 허용할 때 사용합니다. 시각 입력은 서버 시간
|
||||||
|
UTC+9 기준입니다.
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col md:flex-row gap-2">
|
<div class="flex flex-col md:flex-row gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -1762,11 +1764,7 @@ onMounted(() => {
|
|||||||
<td class="text-center">{{ policy.accessGraceDays }}일</td>
|
<td class="text-center">{{ policy.accessGraceDays }}일</td>
|
||||||
<td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td>
|
<td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
{{
|
{{ policy.graceEndsAt ? formatServerDateTime(policy.graceEndsAt) : '-' }}
|
||||||
policy.graceEndsAt
|
|
||||||
? new Date(policy.graceEndsAt).toLocaleString('ko-KR')
|
|
||||||
: '-'
|
|
||||||
}}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -1778,7 +1776,7 @@ onMounted(() => {
|
|||||||
v-if="userWorkspaceSection === 'restrictions'"
|
v-if="userWorkspaceSection === 'restrictions'"
|
||||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||||
>
|
>
|
||||||
<h4 class="text-base font-semibold">유저 차단</h4>
|
<h4 class="text-base font-semibold">유저 차단 (서버 시간 UTC+9)</h4>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="banUntil"
|
v-model="banUntil"
|
||||||
@@ -1817,7 +1815,7 @@ onMounted(() => {
|
|||||||
v-if="userWorkspaceSection === 'restrictions'"
|
v-if="userWorkspaceSection === 'restrictions'"
|
||||||
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
|
||||||
>
|
>
|
||||||
<h4 class="text-base font-semibold">서버별 기능 제재</h4>
|
<h4 class="text-base font-semibold">서버별 기능 제재 (서버 시간 UTC+9)</h4>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="restrictionProfile"
|
v-model="restrictionProfile"
|
||||||
@@ -1936,9 +1934,7 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
{{ event.outcome }} · {{ event.action }}
|
{{ event.outcome }} · {{ event.action }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-zinc-500">{{
|
<span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
|
||||||
new Date(event.createdAt).toLocaleString('ko-KR')
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-zinc-400">
|
<div class="text-zinc-400">
|
||||||
{{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }}
|
{{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }}
|
||||||
@@ -1988,9 +1984,7 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
{{ event.outcome }} · {{ event.action }}
|
{{ event.outcome }} · {{ event.action }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-zinc-500">{{
|
<span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
|
||||||
new Date(event.createdAt).toLocaleString('ko-KR')
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-zinc-400">
|
<div class="text-zinc-400">
|
||||||
{{ event.actorUsername }} · {{ event.targetType ?? '-' }}
|
{{ event.actorUsername }} · {{ event.targetType ?? '-' }}
|
||||||
@@ -2067,9 +2061,14 @@ onMounted(() => {
|
|||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-base font-semibold">
|
<div class="text-base font-semibold">
|
||||||
{{ profile.profileName }} ({{ profile.profile }})
|
{{ profile.meta.korName ?? profile.profile }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">
|
||||||
|
서버 ID: {{ profile.profileName }} · 인스턴스: {{ profile.instanceKey }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500">
|
||||||
|
현재 시나리오: {{ profile.currentScenario ?? '미설정' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-500">시나리오: {{ profile.scenario }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-400">
|
<div class="text-xs text-zinc-400">
|
||||||
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime } from '@sammo-ts/common';
|
||||||
import { computed, ref, onMounted, watch } from 'vue';
|
import { computed, ref, onMounted, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import type { inferRouterOutputs } from '@trpc/server';
|
import type { inferRouterOutputs } from '@trpc/server';
|
||||||
@@ -95,8 +96,7 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
|
|||||||
tabButtons?.[nextIndex]?.focus();
|
tabButtons?.[nextIndex]?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatGraceEndsAt = (value: string | null | undefined): string =>
|
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
|
||||||
value ? new Date(value).toLocaleString('ko-KR') : '';
|
|
||||||
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
||||||
const encodeLegacyIconPath = (value: string): string =>
|
const encodeLegacyIconPath = (value: string): string =>
|
||||||
value
|
value
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common';
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
||||||
@@ -213,21 +214,11 @@ const sourceHelp = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const toIso = (value: string): string | undefined => {
|
const toIso = (value: string): string | undefined => {
|
||||||
if (!value) {
|
return serverDateTimeInputToIso(value);
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const parsed = new Date(value);
|
|
||||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-');
|
const formatTime = (value?: string): string => formatServerDateTime(value, { fallback: '-' });
|
||||||
const formatLogTime = (value: string): string =>
|
const formatLogTime = (value: string): string => formatServerDateTime(value, { format: 'timeSeconds' });
|
||||||
new Date(value).toLocaleTimeString('ko-KR', {
|
|
||||||
hour12: false,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
});
|
|
||||||
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
|
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
|
||||||
|
|
||||||
const clearStatus = () => {
|
const clearStatus = () => {
|
||||||
@@ -959,7 +950,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3">
|
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3">
|
||||||
<label class="text-xs text-zinc-400"
|
<label class="text-xs text-zinc-400"
|
||||||
>작업 예약
|
>작업 예약 (서버 시간 UTC+9)
|
||||||
<input
|
<input
|
||||||
v-model="form.scheduledAt"
|
v-model="form.scheduledAt"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@@ -967,7 +958,7 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="text-xs text-zinc-400"
|
<label class="text-xs text-zinc-400"
|
||||||
>가오픈
|
>가오픈 (서버 시간 UTC+9)
|
||||||
<input
|
<input
|
||||||
v-model="form.preopenAt"
|
v-model="form.preopenAt"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@@ -975,7 +966,7 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="text-xs text-zinc-400"
|
<label class="text-xs text-zinc-400"
|
||||||
>정식 오픈
|
>정식 오픈 (서버 시간 UTC+9)
|
||||||
<input
|
<input
|
||||||
v-model="form.openAt"
|
v-model="form.openAt"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@@ -1302,10 +1293,7 @@ onBeforeUnmount(() => {
|
|||||||
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
|
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table
|
<table class="w-full min-w-[1300px] table-fixed text-left text-sm" data-testid="operations-table">
|
||||||
class="w-full min-w-[1300px] table-fixed text-left text-sm"
|
|
||||||
data-testid="operations-table"
|
|
||||||
>
|
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style="width: 160px" />
|
<col style="width: 160px" />
|
||||||
<col style="width: 264px" />
|
<col style="width: 264px" />
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
|||||||
- 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와
|
- 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와
|
||||||
시나리오 초기화가 같은 서버 아래의 상단 탭으로 노출됩니다. 현재 탭은 색상과
|
시나리오 초기화가 같은 서버 아래의 상단 탭으로 노출됩니다. 현재 탭은 색상과
|
||||||
`aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다.
|
`aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다.
|
||||||
|
- `profileName`은 `${profile}:${instanceKey}` 형식의 불변 기술 ID입니다.
|
||||||
|
`che:default`의 `default`는 현재 시나리오가 아니라 기본 인스턴스 키입니다.
|
||||||
|
좌측 메뉴는 기본 인스턴스의 suffix를 숨기고 표시명만 보여 주며, 상태 상세에서
|
||||||
|
기술 ID·인스턴스 키·nullable 현재 시나리오를 분리해 확인할 수 있습니다.
|
||||||
- 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가
|
- 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가
|
||||||
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
|
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
|
||||||
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
|
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
|
||||||
@@ -45,7 +49,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
|||||||
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
|
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
|
||||||
권한과 버전 배포 권한이 모두 필요합니다.
|
권한과 버전 배포 권한이 모두 필요합니다.
|
||||||
- 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와
|
- 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와
|
||||||
분리된 요청으로 읽습니다. API가 profile의 현재 scenario를 표시하며 화면은
|
분리된 요청으로 읽습니다. API가 profile의 `currentScenario`를 표시하며 화면은
|
||||||
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
|
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
|
||||||
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
|
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
|
||||||
- 서버 상태의 `서버 리셋 기본 옵션`은 `GatewayProfile.meta.resetDefaults`에
|
- 서버 상태의 `서버 리셋 기본 옵션`은 `GatewayProfile.meta.resetDefaults`에
|
||||||
|
|||||||
@@ -131,6 +131,13 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면
|
|||||||
확인합니다.
|
확인합니다.
|
||||||
6. 모두 준비된 경우에만 현재·이전 commit과 workspace를 게시합니다.
|
6. 모두 준비된 경우에만 현재·이전 commit과 workspace를 게시합니다.
|
||||||
|
|
||||||
|
Gateway migration은 앱 rollback 때 자동으로 역방향 적용되지 않습니다. 따라서
|
||||||
|
profile identity 분리의 첫 단계는 기존 `profile_name`과 legacy `scenario`를 유지한
|
||||||
|
채 `instance_key`와 nullable `current_scenario`를 추가합니다. DB trigger가 구버전의
|
||||||
|
`scenario` write와 신버전의 `current_scenario` write를 양방향 동기화하므로 migration
|
||||||
|
적용 뒤 readiness가 실패해 직전 Gateway worktree로 돌아가도 기존 DB와 시즌을
|
||||||
|
그대로 사용할 수 있습니다.
|
||||||
|
|
||||||
release-controller는 PM2 process 안에서 실행되므로 부모의 `args`, `pm_id`,
|
release-controller는 PM2 process 안에서 실행되므로 부모의 `args`, `pm_id`,
|
||||||
`pm_exec_path`, `name`, `NODE_APP_INSTANCE`와 `axm_*` 같은 PM2 내부 값을 자식
|
`pm_exec_path`, `name`, `NODE_APP_INSTANCE`와 `axm_*` 같은 PM2 내부 값을 자식
|
||||||
환경으로 전달하지 않습니다. 특히 부모의 `args=daemon`이 frontend의
|
환경으로 전달하지 않습니다. 특히 부모의 `args=daemon`이 frontend의
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './rng.js';
|
export * from './rng.js';
|
||||||
export * from './time/Clock.js';
|
export * from './time/Clock.js';
|
||||||
export * from './time/GameClock.js';
|
export * from './time/GameClock.js';
|
||||||
|
export * from './time/ServerDateTime.js';
|
||||||
export * from './util/BytesLike.js';
|
export * from './util/BytesLike.js';
|
||||||
export * from './util/convertBytesLikeToArrayBuffer.js';
|
export * from './util/convertBytesLikeToArrayBuffer.js';
|
||||||
export * from './util/convertBytesLikeToUint8Array.js';
|
export * from './util/convertBytesLikeToUint8Array.js';
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
const SERVER_UTC_OFFSET_MINUTES = 9 * 60;
|
||||||
|
const SERVER_UTC_OFFSET_MS = SERVER_UTC_OFFSET_MINUTES * 60_000;
|
||||||
|
|
||||||
|
export type ServerDateTimeFormat =
|
||||||
|
| 'dateTimeSeconds'
|
||||||
|
| 'dateTimeMinutes'
|
||||||
|
| 'date'
|
||||||
|
| 'timeSeconds'
|
||||||
|
| 'hourMinute'
|
||||||
|
| 'minuteSecond'
|
||||||
|
| 'monthDayTime'
|
||||||
|
| 'monthDayTimeSeconds';
|
||||||
|
|
||||||
|
export type ServerDateTimeOptions = {
|
||||||
|
format?: ServerDateTimeFormat;
|
||||||
|
fallback?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DateTimeParts = {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
second: number;
|
||||||
|
millisecond: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERVER_WALL_TIME_PATTERN = /^(\d{4,6})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?$/u;
|
||||||
|
|
||||||
|
const pad = (value: number, length = 2): string => String(value).padStart(length, '0');
|
||||||
|
|
||||||
|
const isValidParts = (parts: DateTimeParts): boolean => {
|
||||||
|
const candidate = new Date(0);
|
||||||
|
candidate.setUTCFullYear(parts.year, parts.month - 1, parts.day);
|
||||||
|
candidate.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
|
||||||
|
return (
|
||||||
|
candidate.getUTCFullYear() === parts.year &&
|
||||||
|
candidate.getUTCMonth() + 1 === parts.month &&
|
||||||
|
candidate.getUTCDate() === parts.day &&
|
||||||
|
candidate.getUTCHours() === parts.hour &&
|
||||||
|
candidate.getUTCMinutes() === parts.minute &&
|
||||||
|
candidate.getUTCSeconds() === parts.second &&
|
||||||
|
candidate.getUTCMilliseconds() === parts.millisecond
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseServerWallTime = (value: string): DateTimeParts | null => {
|
||||||
|
const match = SERVER_WALL_TIME_PATTERN.exec(value.trim());
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const millisecondText = match[7] ?? '';
|
||||||
|
const parts: DateTimeParts = {
|
||||||
|
year: Number(match[1]),
|
||||||
|
month: Number(match[2]),
|
||||||
|
day: Number(match[3]),
|
||||||
|
hour: Number(match[4] ?? 0),
|
||||||
|
minute: Number(match[5] ?? 0),
|
||||||
|
second: Number(match[6] ?? 0),
|
||||||
|
millisecond: Number(millisecondText.padEnd(3, '0')),
|
||||||
|
};
|
||||||
|
return isValidParts(parts) ? parts : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const partsFromInstant = (value: string | Date): DateTimeParts | null => {
|
||||||
|
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const shifted = new Date(date.getTime() + SERVER_UTC_OFFSET_MS);
|
||||||
|
return {
|
||||||
|
year: shifted.getUTCFullYear(),
|
||||||
|
month: shifted.getUTCMonth() + 1,
|
||||||
|
day: shifted.getUTCDate(),
|
||||||
|
hour: shifted.getUTCHours(),
|
||||||
|
minute: shifted.getUTCMinutes(),
|
||||||
|
second: shifted.getUTCSeconds(),
|
||||||
|
millisecond: shifted.getUTCMilliseconds(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveParts = (value: string | Date): DateTimeParts | null => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const wallTime = parseServerWallTime(value);
|
||||||
|
if (wallTime) {
|
||||||
|
return wallTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return partsFromInstant(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatParts = (parts: DateTimeParts, format: ServerDateTimeFormat): string => {
|
||||||
|
const year = pad(parts.year, 4);
|
||||||
|
const month = pad(parts.month);
|
||||||
|
const day = pad(parts.day);
|
||||||
|
const hour = pad(parts.hour);
|
||||||
|
const minute = pad(parts.minute);
|
||||||
|
const second = pad(parts.second);
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'dateTimeMinutes':
|
||||||
|
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||||
|
case 'date':
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
case 'timeSeconds':
|
||||||
|
return `${hour}:${minute}:${second}`;
|
||||||
|
case 'hourMinute':
|
||||||
|
return `${hour}:${minute}`;
|
||||||
|
case 'minuteSecond':
|
||||||
|
return `${minute}:${second}`;
|
||||||
|
case 'monthDayTime':
|
||||||
|
return `${month}-${day} ${hour}:${minute}`;
|
||||||
|
case 'monthDayTimeSeconds':
|
||||||
|
return `${month}-${day} ${hour}:${minute}:${second}`;
|
||||||
|
case 'dateTimeSeconds':
|
||||||
|
default:
|
||||||
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an instant in the service's fixed UTC+9 wall clock.
|
||||||
|
*
|
||||||
|
* Timezone-less legacy DATETIME strings are already server wall-clock values and
|
||||||
|
* therefore keep their components. This deliberate fixed offset also avoids
|
||||||
|
* historical IANA timezone rules changing ancient in-game years.
|
||||||
|
*/
|
||||||
|
export const formatServerDateTime = (
|
||||||
|
value: string | Date | null | undefined,
|
||||||
|
options: ServerDateTimeOptions = {}
|
||||||
|
): string => {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return options.fallback ?? '';
|
||||||
|
}
|
||||||
|
const parts = resolveParts(value);
|
||||||
|
if (!parts) {
|
||||||
|
return options.fallback ?? String(value);
|
||||||
|
}
|
||||||
|
return formatParts(parts, options.format ?? 'dateTimeSeconds');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toServerDateTimeInputValue = (value: string | Date | null | undefined): string => {
|
||||||
|
const formatted = formatServerDateTime(value, { format: 'dateTimeMinutes', fallback: '' });
|
||||||
|
return formatted ? formatted.replace(' ', 'T') : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Converts an HTML datetime-local value, interpreted as UTC+9 server wall time, to ISO UTC. */
|
||||||
|
export const serverDateTimeInputToIso = (value: string): string | undefined => {
|
||||||
|
const parts = parseServerWallTime(value);
|
||||||
|
if (!parts) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const wallTime = new Date(0);
|
||||||
|
wallTime.setUTCFullYear(parts.year, parts.month - 1, parts.day);
|
||||||
|
wallTime.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
|
||||||
|
return new Date(wallTime.getTime() - SERVER_UTC_OFFSET_MS).toISOString();
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatServerDateTime,
|
||||||
|
serverDateTimeInputToIso,
|
||||||
|
toServerDateTimeInputValue,
|
||||||
|
} from '../src/time/ServerDateTime.js';
|
||||||
|
|
||||||
|
describe('formatServerDateTime', () => {
|
||||||
|
it('formats ISO instants with the fixed UTC+9 service offset', () => {
|
||||||
|
expect(formatServerDateTime('2026-08-13T00:05:06.000Z')).toBe('2026-08-13 09:05:06');
|
||||||
|
expect(formatServerDateTime('0185-01-02T00:04:05.000Z')).toBe('0185-01-02 09:04:05');
|
||||||
|
expect(formatServerDateTime('2026-08-13T18:05:06.000Z', { format: 'date' })).toBe('2026-08-14');
|
||||||
|
expect(formatServerDateTime('2026-08-13T18:05:06.000Z', { format: 'hourMinute' })).toBe('03:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves timezone-less legacy wall-clock values', () => {
|
||||||
|
expect(formatServerDateTime('0185-01-02 03:04:05')).toBe('0185-01-02 03:04:05');
|
||||||
|
expect(formatServerDateTime('0185-01-02T03:04:05', { format: 'monthDayTime' })).toBe('01-02 03:04');
|
||||||
|
expect(formatServerDateTime('2026-08-13 09:05:06', { format: 'minuteSecond' })).toBe('05:06');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers explicit shapes and predictable fallbacks', () => {
|
||||||
|
const value = '2026-08-13T00:05:06.000Z';
|
||||||
|
expect(formatServerDateTime(value, { format: 'dateTimeMinutes' })).toBe('2026-08-13 09:05');
|
||||||
|
expect(formatServerDateTime(value, { format: 'timeSeconds' })).toBe('09:05:06');
|
||||||
|
expect(formatServerDateTime(value, { format: 'monthDayTimeSeconds' })).toBe('08-13 09:05:06');
|
||||||
|
expect(formatServerDateTime(undefined, { fallback: '-' })).toBe('-');
|
||||||
|
expect(formatServerDateTime('not-a-date')).toBe('not-a-date');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('server datetime-local conversion', () => {
|
||||||
|
it('does not depend on the browser or process timezone', () => {
|
||||||
|
expect(serverDateTimeInputToIso('2026-08-13T09:05')).toBe('2026-08-13T00:05:00.000Z');
|
||||||
|
expect(toServerDateTimeInputValue('2026-08-13T00:05:00.000Z')).toBe('2026-08-13T09:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid local input', () => {
|
||||||
|
expect(serverDateTimeInputToIso('2026-02-30T09:05')).toBeUndefined();
|
||||||
|
expect(serverDateTimeInputToIso('')).toBeUndefined();
|
||||||
|
expect(toServerDateTimeInputValue('not-a-date')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
-- Keep profile_name stable because it is referenced by operations, runtime
|
||||||
|
-- actions, permission scopes, process names, Redis namespaces, and routes.
|
||||||
|
-- instance_key identifies the immutable slot while current_scenario records the
|
||||||
|
-- mutable game selection. The legacy scenario column remains during the
|
||||||
|
-- expansion phase so the previous Gateway release can still be restored.
|
||||||
|
ALTER TABLE "gateway_profile"
|
||||||
|
ADD COLUMN "instance_key" TEXT,
|
||||||
|
ADD COLUMN "current_scenario" TEXT;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "gateway_profile"
|
||||||
|
WHERE left("profile_name", length("profile") + 1) <> "profile" || ':'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'gateway_profile.profile_name must start with profile followed by a colon';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
UPDATE "gateway_profile"
|
||||||
|
SET
|
||||||
|
"instance_key" = substring("profile_name" FROM length("profile") + 2),
|
||||||
|
"current_scenario" = NULLIF("scenario", 'default');
|
||||||
|
|
||||||
|
ALTER TABLE "gateway_profile"
|
||||||
|
ALTER COLUMN "instance_key" SET NOT NULL;
|
||||||
|
|
||||||
|
DROP INDEX "gateway_profile_profile_scenario_key";
|
||||||
|
|
||||||
|
ALTER TABLE "gateway_profile"
|
||||||
|
ADD CONSTRAINT "gateway_profile_profile_instance_key_key" UNIQUE ("profile", "instance_key"),
|
||||||
|
ADD CONSTRAINT "gateway_profile_identity_check"
|
||||||
|
CHECK (
|
||||||
|
length("instance_key") > 0
|
||||||
|
AND "profile_name" = "profile" || ':' || "instance_key"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE FUNCTION "sync_gateway_profile_scenario_compat"()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW."instance_key" IS NULL THEN
|
||||||
|
IF left(NEW."profile_name", length(NEW."profile") + 1) <> NEW."profile" || ':' THEN
|
||||||
|
RAISE EXCEPTION 'gateway_profile.profile_name must start with profile followed by a colon';
|
||||||
|
END IF;
|
||||||
|
NEW."instance_key" := substring(NEW."profile_name" FROM length(NEW."profile") + 2);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
IF NEW."current_scenario" IS NULL THEN
|
||||||
|
NEW."current_scenario" := NULLIF(NEW."scenario", 'default');
|
||||||
|
ELSE
|
||||||
|
NEW."scenario" := NEW."current_scenario";
|
||||||
|
END IF;
|
||||||
|
ELSIF NEW."current_scenario" IS DISTINCT FROM OLD."current_scenario" THEN
|
||||||
|
NEW."scenario" := COALESCE(NEW."current_scenario", 'default');
|
||||||
|
ELSIF NEW."scenario" IS DISTINCT FROM OLD."scenario" THEN
|
||||||
|
NEW."current_scenario" := NULLIF(NEW."scenario", 'default');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER "gateway_profile_scenario_compat"
|
||||||
|
BEFORE INSERT OR UPDATE ON "gateway_profile"
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION "sync_gateway_profile_scenario_compat"();
|
||||||
@@ -208,6 +208,10 @@ model LegacyRootKeyValue {
|
|||||||
model GatewayProfile {
|
model GatewayProfile {
|
||||||
profileName String @id @map("profile_name")
|
profileName String @id @map("profile_name")
|
||||||
profile String
|
profile String
|
||||||
|
instanceKey String @map("instance_key")
|
||||||
|
currentScenario String? @map("current_scenario")
|
||||||
|
/// Legacy compatibility mirror. The database trigger keeps this synchronized
|
||||||
|
/// with currentScenario while the previous Gateway release remains rollbackable.
|
||||||
scenario String
|
scenario String
|
||||||
apiPort Int @map("api_port")
|
apiPort Int @map("api_port")
|
||||||
status GatewayProfileStatus
|
status GatewayProfileStatus
|
||||||
@@ -229,7 +233,7 @@ model GatewayProfile {
|
|||||||
operations GatewayOperation[]
|
operations GatewayOperation[]
|
||||||
runtimeActions GatewayRuntimeAction[]
|
runtimeActions GatewayRuntimeAction[]
|
||||||
|
|
||||||
@@unique([profile, scenario])
|
@@unique([profile, instanceKey])
|
||||||
@@map("gateway_profile")
|
@@map("gateway_profile")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"controllerProtocol": 2,
|
"controllerProtocol": 2,
|
||||||
"gatewaySchemaHead": "20260811000000_add_gateway_operation_logs",
|
"gatewaySchemaHead": "20260813000000_split_gateway_profile_identity",
|
||||||
"gameSchemaHead": "20260803000000_add_logical_game_clock",
|
"gameSchemaHead": "20260803000000_add_logical_game_clock",
|
||||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,11 +293,14 @@ test('renders actual online, nation policy, and survey data with ref geometry an
|
|||||||
await expect(status).toContainText(marker);
|
await expect(status).toContainText(marker);
|
||||||
await expect(page.locator('.online-users')).toContainText('현황검증장수');
|
await expect(page.locator('.online-users')).toContainText('현황검증장수');
|
||||||
await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.');
|
await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.');
|
||||||
|
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
|
||||||
|
await expect(page.locator('.vote-status')).toHaveText('설문: 검증 설문');
|
||||||
const desktop = await status.evaluate((element) => {
|
const desktop = await status.evaluate((element) => {
|
||||||
const style = getComputedStyle(element);
|
const style = getComputedStyle(element);
|
||||||
const onlineRow = element.querySelector<HTMLElement>('.online-nations');
|
const onlineRow = element.querySelector<HTMLElement>('.online-nations');
|
||||||
const voteRow = element.querySelector<HTMLElement>('.vote-status');
|
const voteRow = element.querySelector<HTMLElement>('.vote-status');
|
||||||
if (!onlineRow || !voteRow) throw new Error('status row missing');
|
const tournamentRow = element.querySelector<HTMLElement>('.tournament-status');
|
||||||
|
if (!onlineRow || !voteRow || !tournamentRow) throw new Error('status row missing');
|
||||||
return {
|
return {
|
||||||
rect: element.getBoundingClientRect().toJSON(),
|
rect: element.getBoundingClientRect().toJSON(),
|
||||||
fontSize: style.fontSize,
|
fontSize: style.fontSize,
|
||||||
@@ -308,6 +311,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an
|
|||||||
borderTop: getComputedStyle(onlineRow).borderTop,
|
borderTop: getComputedStyle(onlineRow).borderTop,
|
||||||
padding: getComputedStyle(onlineRow).padding,
|
padding: getComputedStyle(onlineRow).padding,
|
||||||
},
|
},
|
||||||
|
tournamentRow: tournamentRow.getBoundingClientRect().toJSON(),
|
||||||
voteRow: voteRow.getBoundingClientRect().toJSON(),
|
voteRow: voteRow.getBoundingClientRect().toJSON(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -321,6 +325,9 @@ test('renders actual online, nation policy, and survey data with ref geometry an
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(desktop.onlineRow.rect.height).toBeCloseTo(36, 0);
|
expect(desktop.onlineRow.rect.height).toBeCloseTo(36, 0);
|
||||||
|
expect(desktop.tournamentRow.x).toBeCloseTo(333.33, 0);
|
||||||
|
expect(desktop.tournamentRow.width).toBeCloseTo(333.33, 0);
|
||||||
|
expect(desktop.tournamentRow.height).toBeCloseTo(36, 0);
|
||||||
expect(desktop.voteRow.x).toBeCloseTo(666.67, 0);
|
expect(desktop.voteRow.x).toBeCloseTo(666.67, 0);
|
||||||
expect(desktop.voteRow.width).toBeCloseTo(333.33, 0);
|
expect(desktop.voteRow.width).toBeCloseTo(333.33, 0);
|
||||||
expect(desktop.voteRow.height).toBeCloseTo(36, 0);
|
expect(desktop.voteRow.height).toBeCloseTo(36, 0);
|
||||||
@@ -328,6 +335,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an
|
|||||||
|
|
||||||
await page.setViewportSize({ width: 500, height: 900 });
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
await expect(status).toHaveCSS('width', '500px');
|
await expect(status).toHaveCSS('width', '500px');
|
||||||
|
await expect(page.locator('.tournament-status')).toHaveCSS('width', '250px');
|
||||||
await expect(page.locator('.vote-status')).toHaveCSS('width', '250px');
|
await expect(page.locator('.vote-status')).toHaveCSS('width', '250px');
|
||||||
|
|
||||||
failStatus = true;
|
failStatus = true;
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
const baseUrl = process.env.REF_GENERAL_URL ?? 'http://127.0.0.1:3416/sam/';
|
||||||
|
const username = process.env.REF_GENERAL_USER ?? 's100user01';
|
||||||
|
const passwordFile = process.env.REF_GENERAL_PASSWORD_FILE;
|
||||||
|
const artifactRoot = resolve(process.env.REF_GENERAL_ARTIFACT_DIR ?? 'test-results/reference-nation-general-controls');
|
||||||
|
|
||||||
|
if (!passwordFile) throw new Error('REF_GENERAL_PASSWORD_FILE is required.');
|
||||||
|
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
|
||||||
|
const measure = async (page) =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const describe = (element) => {
|
||||||
|
if (!(element instanceof HTMLElement)) return null;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||||
|
style: {
|
||||||
|
fontFamily: style.fontFamily,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
color: style.color,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
borderCollapse: style.borderCollapse,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const headerGroups = [...document.querySelectorAll('.ag-header-group-cell')].map((element) => ({
|
||||||
|
text: element.textContent?.trim() ?? '',
|
||||||
|
className: element.className,
|
||||||
|
expanded: element.getAttribute('aria-expanded'),
|
||||||
|
rect: describe(element)?.rect,
|
||||||
|
}));
|
||||||
|
const columns = [...document.querySelectorAll('.ag-header-cell')].map((element) => ({
|
||||||
|
id: element.getAttribute('col-id'),
|
||||||
|
text: element.textContent?.trim() ?? '',
|
||||||
|
sort: element.getAttribute('aria-sort'),
|
||||||
|
}));
|
||||||
|
const inputs = [...document.querySelectorAll('.ag-header-row-column-filter input.ag-text-field-input')].map(
|
||||||
|
(element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const center = rect.x + rect.width / 2;
|
||||||
|
const matchingHeader = [...document.querySelectorAll('.ag-header-row-column .ag-header-cell')].find(
|
||||||
|
(candidate) => {
|
||||||
|
const headerRect = candidate.getBoundingClientRect();
|
||||||
|
return center >= headerRect.x && center <= headerRect.right;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
colId: matchingHeader?.getAttribute('col-id') ?? null,
|
||||||
|
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||||
|
placeholder: element.getAttribute('placeholder'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
url: location.href,
|
||||||
|
documentWidth: document.documentElement.scrollWidth,
|
||||||
|
body: describe(document.body),
|
||||||
|
page: describe(document.querySelector('.pageNationGeneral')),
|
||||||
|
component: describe(document.querySelector('.component-general-list')),
|
||||||
|
grid: describe(document.querySelector('.ag-root-wrapper')),
|
||||||
|
firstRow: describe(document.querySelector('.ag-row')),
|
||||||
|
headerGroups,
|
||||||
|
columns,
|
||||||
|
inputs,
|
||||||
|
toolbarText: document.querySelector('.component-general-list')?.textContent?.slice(0, 500) ?? '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1200, height: 900 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: 'ko-KR',
|
||||||
|
colorScheme: 'dark',
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||||
|
const salt = await page.locator('#global_salt').inputValue();
|
||||||
|
const passwordHash = createHash('sha512')
|
||||||
|
.update(salt + password + salt)
|
||||||
|
.digest('hex');
|
||||||
|
const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
|
||||||
|
data: { username, password: passwordHash },
|
||||||
|
});
|
||||||
|
const loginResult = await login.json();
|
||||||
|
if (!login.ok() || loginResult.result !== true) throw new Error('Reference login failed.');
|
||||||
|
|
||||||
|
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.goto(new URL('hwe/v_nationGeneral.php', baseUrl).toString(), { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.locator('.ag-root-wrapper').waitFor();
|
||||||
|
await page.locator('.ag-center-cols-container .ag-row').first().waitFor();
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
|
||||||
|
const output = { initial: await measure(page) };
|
||||||
|
await page.screenshot({ path: resolve(artifactRoot, 'ref-initial.png'), fullPage: true });
|
||||||
|
|
||||||
|
const statGroup = page.locator('.ag-header-group-cell').filter({ hasText: '능력치' });
|
||||||
|
await statGroup.locator('.ag-header-expand-icon:visible').click();
|
||||||
|
output.collapsed = await measure(page);
|
||||||
|
await page.screenshot({ path: resolve(artifactRoot, 'ref-stat-collapsed.png'), fullPage: true });
|
||||||
|
|
||||||
|
const leadershipHeader = page.locator('.ag-header-cell[col-id="leadership"] .ag-header-cell-label');
|
||||||
|
await statGroup.locator('.ag-header-expand-icon:visible').click();
|
||||||
|
await leadershipHeader.click();
|
||||||
|
output.sort = {
|
||||||
|
ariaSort: await page.locator('.ag-header-cell[col-id="leadership"]').getAttribute('aria-sort'),
|
||||||
|
firstValue: await page.locator('.ag-center-cols-container .ag-row [col-id="leadership"]').first().innerText(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await statGroup.locator('.ag-header-expand-icon:visible').click();
|
||||||
|
await page.getByRole('button', { name: /보기 모드/ }).click();
|
||||||
|
page.once('dialog', (dialog) => dialog.accept('Ref 캡처'));
|
||||||
|
await page.getByText('보관하기', { exact: true }).click();
|
||||||
|
output.savedSetting = await page.evaluate(() => ({
|
||||||
|
settings: localStorage.getItem('GeneralListDisplaySetting'),
|
||||||
|
last: localStorage.getItem('LastUsedSettingsKey_pageNationGeneral'),
|
||||||
|
}));
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await page.locator('.ag-root-wrapper').waitFor();
|
||||||
|
await page.locator('.ag-center-cols-container .ag-row').first().waitFor();
|
||||||
|
output.reloaded = await measure(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /보기 모드/ }).click();
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await page.getByRole('button', { name: '삭제', exact: true }).click();
|
||||||
|
output.deletedSetting = await page.evaluate(() => localStorage.getItem('GeneralListDisplaySetting'));
|
||||||
|
|
||||||
|
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`);
|
||||||
|
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, output })}\n`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user