Merge branch 'main' into chore/build-toolchain-20260812
This commit is contained in:
@@ -10,6 +10,11 @@ export type BaseMapResult = {
|
|||||||
startYear: number;
|
startYear: number;
|
||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
|
techLevelLimit: {
|
||||||
|
maxLevel: number;
|
||||||
|
initialLevel: number;
|
||||||
|
increaseYears: number;
|
||||||
|
};
|
||||||
cityList: MapCityCompact[];
|
cityList: MapCityCompact[];
|
||||||
nationList: MapNationCompact[];
|
nationList: MapNationCompact[];
|
||||||
};
|
};
|
||||||
@@ -64,6 +69,23 @@ const readState = (meta: Record<string, unknown>): number => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readPositiveInteger = (value: unknown, fallback: number): number => {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const normalized = Math.floor(value);
|
||||||
|
return normalized > 0 ? normalized : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTechLevelLimit = (worldState: WorldStateRow): BaseMapResult['techLevelLimit'] => {
|
||||||
|
const constValues = asRecord(asRecord(worldState.config).const);
|
||||||
|
return {
|
||||||
|
maxLevel: readPositiveInteger(constValues.maxTechLevel, 12),
|
||||||
|
initialLevel: readPositiveInteger(constValues.initialAllowedTechLevel, 1),
|
||||||
|
increaseYears: readPositiveInteger(constValues.techLevelIncYear, 5),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeNumberRecord = (value: unknown): Record<number, number> => {
|
const normalizeNumberRecord = (value: unknown): Record<number, number> => {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return {};
|
return {};
|
||||||
@@ -180,6 +202,7 @@ const loadBaseMap = async (
|
|||||||
startYear: resolveStartYear(worldState),
|
startYear: resolveStartYear(worldState),
|
||||||
year: worldState.currentYear,
|
year: worldState.currentYear,
|
||||||
month: worldState.currentMonth,
|
month: worldState.currentMonth,
|
||||||
|
techLevelLimit: resolveTechLevelLimit(worldState),
|
||||||
cityList,
|
cityList,
|
||||||
nationList,
|
nationList,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTranspor
|
|||||||
import { resolveAccessWindows } from '../../services/generalAccess.js';
|
import { resolveAccessWindows } from '../../services/generalAccess.js';
|
||||||
import { adjustAccountIconForUser } from '../../services/accountIconSync.js';
|
import { adjustAccountIconForUser } from '../../services/accountIconSync.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
import { resolveNationNotice } from '../nation/shared.js';
|
import { loadTraitNames, resolveNationNotice, type TraitNameMap } from '../nation/shared.js';
|
||||||
|
|
||||||
const zGeneralSettings = z.object({
|
const zGeneralSettings = z.object({
|
||||||
tnmt: z.number().int().optional(),
|
tnmt: z.number().int().optional(),
|
||||||
@@ -34,6 +34,17 @@ const zImmediateActionInput = z
|
|||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
const MAIN_RECORD_LIMIT = 15;
|
const MAIN_RECORD_LIMIT = 15;
|
||||||
|
const NEUTRAL_NATION_CONTEXT = {
|
||||||
|
id: 0,
|
||||||
|
name: '재야',
|
||||||
|
color: '#000000',
|
||||||
|
level: 0,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
tech: 0,
|
||||||
|
typeCode: 'None',
|
||||||
|
capitalCityId: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
const resolveImmediateActionRequestId = (
|
const resolveImmediateActionRequestId = (
|
||||||
contextRequestId: string | undefined,
|
contextRequestId: string | undefined,
|
||||||
@@ -135,6 +146,18 @@ const normalizeItemCode = (value: string | null): string | null => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveTraitDisplayName = (code: string, names: TraitNameMap): string => {
|
||||||
|
if (!code || code === 'None') {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
const loadedName = names.get(code)?.name;
|
||||||
|
if (loadedName) {
|
||||||
|
return loadedName;
|
||||||
|
}
|
||||||
|
// Ref는 class getName()을 표시하므로 로더가 모르는 선택적 특기도 raw namespace는 노출하지 않는다.
|
||||||
|
return code.replace(/^che_(?:event_)?/u, '');
|
||||||
|
};
|
||||||
|
|
||||||
const resolveUserSettings = (meta: Record<string, unknown>) => {
|
const resolveUserSettings = (meta: Record<string, unknown>) => {
|
||||||
// The legacy general columns are persisted at the top level of General.meta.
|
// The legacy general columns are persisted at the top level of General.meta.
|
||||||
// Keep reading the short-lived nested shape for installations that ran the
|
// Keep reading the short-lived nested shape for installations that ran the
|
||||||
@@ -265,10 +288,16 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
capitalCityId: true,
|
capitalCityId: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: null,
|
: Promise.resolve(NEUTRAL_NATION_CONTEXT),
|
||||||
ctx.db.worldState.findFirst({ select: { config: true } }),
|
ctx.db.worldState.findFirst({ select: { config: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const [personalityNames, domesticNames, warNames] = await Promise.all([
|
||||||
|
loadTraitNames([general.personalCode], 'personality'),
|
||||||
|
loadTraitNames([general.specialCode], 'domestic'),
|
||||||
|
loadTraitNames([general.special2Code], 'war'),
|
||||||
|
]);
|
||||||
|
|
||||||
const metaRecord = asRecord(general.meta);
|
const metaRecord = asRecord(general.meta);
|
||||||
const worldConfig = asRecord(worldState?.config);
|
const worldConfig = asRecord(worldState?.config);
|
||||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||||
@@ -303,9 +332,9 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
turnTime: general.turnTime.toISOString(),
|
turnTime: general.turnTime.toISOString(),
|
||||||
crewTypeId: general.crewTypeId,
|
crewTypeId: general.crewTypeId,
|
||||||
traits: {
|
traits: {
|
||||||
personal: general.personalCode,
|
personal: resolveTraitDisplayName(general.personalCode, personalityNames),
|
||||||
specialWar: general.specialCode,
|
specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames),
|
||||||
specialDomestic: general.special2Code,
|
specialWar: resolveTraitDisplayName(general.special2Code, warNames),
|
||||||
},
|
},
|
||||||
progression: {
|
progression: {
|
||||||
experienceLevel: readNumber(metaRecord.explevel, 0),
|
experienceLevel: readNumber(metaRecord.explevel, 0),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface ReservedTurnView {
|
|||||||
export interface ReservedTurnSnapshot {
|
export interface ReservedTurnSnapshot {
|
||||||
revision: number;
|
revision: number;
|
||||||
turns: ReservedTurnView[];
|
turns: ReservedTurnView[];
|
||||||
|
autorunLimit?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReservedTurnUpdate {
|
export interface ReservedTurnUpdate {
|
||||||
@@ -170,13 +171,19 @@ export const listGeneralTurns = async (db: DatabaseClient, generalId: number): P
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnSnapshot> => {
|
export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnSnapshot> => {
|
||||||
const [turns, revisionRow] = await Promise.all([
|
const [turns, revisionRow, general] = await Promise.all([
|
||||||
loadGeneralTurns(db, generalId),
|
loadGeneralTurns(db, generalId),
|
||||||
db.generalTurnRevision.findUnique({ where: { generalId } }),
|
db.generalTurnRevision.findUnique({ where: { generalId } }),
|
||||||
|
db.general.findUnique({ where: { id: generalId }, select: { meta: true } }),
|
||||||
]);
|
]);
|
||||||
|
const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined;
|
||||||
return {
|
return {
|
||||||
revision: revisionRow?.revision ?? 0,
|
revision: revisionRow?.revision ?? 0,
|
||||||
turns: serializeTurnList(turns),
|
turns: serializeTurnList(turns),
|
||||||
|
autorunLimit:
|
||||||
|
typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit)
|
||||||
|
? Math.trunc(rawAutorunLimit)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,40 @@ describe('in-game my information ownership', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the Ref-style neutral nation frame and trait display names on the main read model', async () => {
|
||||||
|
const fixture = createContext({
|
||||||
|
me: buildGeneral({
|
||||||
|
nationId: 0,
|
||||||
|
officerLevel: 0,
|
||||||
|
personalCode: 'che_안전',
|
||||||
|
specialCode: 'che_상재',
|
||||||
|
special2Code: 'che_신산',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
|
||||||
|
general: {
|
||||||
|
traits: {
|
||||||
|
personal: '안전',
|
||||||
|
specialDomestic: '상재',
|
||||||
|
specialWar: '신산',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
id: 0,
|
||||||
|
name: '재야',
|
||||||
|
color: '#000000',
|
||||||
|
level: 0,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
tech: 0,
|
||||||
|
typeCode: 'None',
|
||||||
|
capitalCityId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fixture.db.nation.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('reads legacy top-level settings and dispatches only the session-owned general', async () => {
|
it('reads legacy top-level settings and dispatches only the session-owned general', async () => {
|
||||||
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
|
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
|
||||||
const fixture = createContext({ requestCommand });
|
const fixture = createContext({ requestCommand });
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ const buildContext = () => {
|
|||||||
findFirst: vi.fn(async () => ({
|
findFirst: vi.fn(async () => ({
|
||||||
currentYear: 190,
|
currentYear: 190,
|
||||||
currentMonth: 3,
|
currentMonth: 3,
|
||||||
config: {},
|
config: {
|
||||||
|
const: {
|
||||||
|
maxTechLevel: 10,
|
||||||
|
initialAllowedTechLevel: 2,
|
||||||
|
techLevelIncYear: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
meta: { scenarioMeta: { startYear: 184 } },
|
meta: { scenarioMeta: { startYear: 184 } },
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -38,12 +44,8 @@ const buildContext = () => {
|
|||||||
},
|
},
|
||||||
$queryRaw: vi
|
$queryRaw: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([{ id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } }])
|
||||||
{ id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } },
|
.mockResolvedValueOnce([{ id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} }]),
|
||||||
])
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} },
|
|
||||||
]),
|
|
||||||
};
|
};
|
||||||
const context: GameApiContext = {
|
const context: GameApiContext = {
|
||||||
db: db as unknown as DatabaseClient,
|
db: db as unknown as DatabaseClient,
|
||||||
@@ -70,6 +72,11 @@ describe('public.getCachedMap', () => {
|
|||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
year: 190,
|
year: 190,
|
||||||
month: 3,
|
month: 3,
|
||||||
|
techLevelLimit: {
|
||||||
|
maxLevel: 10,
|
||||||
|
initialLevel: 2,
|
||||||
|
increaseYears: 4,
|
||||||
|
},
|
||||||
history: [
|
history: [
|
||||||
{ id: 9, text: '<Y>최근 정세</>' },
|
{ id: 9, text: '<Y>최근 정세</>' },
|
||||||
{ id: 8, text: '이전 정세' },
|
{ id: 8, text: '이전 정세' },
|
||||||
|
|||||||
@@ -824,7 +824,7 @@ describe('appRouter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns reserved general turns', async () => {
|
it('returns reserved general turns', async () => {
|
||||||
const general = buildGeneralRow({ id: 11 });
|
const general = buildGeneralRow({ id: 11, meta: { autorun_limit: 2408 } });
|
||||||
const generalTurns: GeneralTurnRow[] = [
|
const generalTurns: GeneralTurnRow[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -839,6 +839,7 @@ describe('appRouter', () => {
|
|||||||
const response = await caller.turns.reserved.getGeneral({ generalId: 11 });
|
const response = await caller.turns.reserved.getGeneral({ generalId: 11 });
|
||||||
|
|
||||||
expect(response.revision).toBe(0);
|
expect(response.revision).toBe(0);
|
||||||
|
expect(response.autorunLimit).toBe(2408);
|
||||||
expect(response.turns[0]?.action).toBe('che_화계');
|
expect(response.turns[0]?.action).toBe('che_화계');
|
||||||
expect(response.turns[0]?.index).toBe(0);
|
expect(response.turns[0]?.index).toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const map = {
|
|||||||
startYear: 180,
|
startYear: 180,
|
||||||
year: 200,
|
year: 200,
|
||||||
month: 1,
|
month: 1,
|
||||||
|
techLevelLimit: { maxLevel: 12, initialLevel: 1, increaseYears: 5 },
|
||||||
cityList: castleFixtures.map(({ id, level }) => [id, level, 0, 1, 1, 1]),
|
cityList: castleFixtures.map(({ id, level }) => [id, level, 0, 1, 1, 1]),
|
||||||
nationList: [[1, '아국', '#008000', 1]],
|
nationList: [[1, '아국', '#008000', 1]],
|
||||||
spyList: {},
|
spyList: {},
|
||||||
@@ -130,7 +131,13 @@ const emptyMessages = {
|
|||||||
canRespondDiplomacy: false,
|
canRespondDiplomacy: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member', trade: number | null = 100) => {
|
const install = async (
|
||||||
|
page: Page,
|
||||||
|
mode: 'member' | 'wanderer' | 'admin' = 'member',
|
||||||
|
trade: number | null = 100,
|
||||||
|
globalNationCount = 2,
|
||||||
|
mapFixture = map
|
||||||
|
) => {
|
||||||
await page.addInitScript((profile) => {
|
await page.addInitScript((profile) => {
|
||||||
localStorage.setItem('sammo-game-token', 'ga_info');
|
localStorage.setItem('sammo-game-token', 'ga_info');
|
||||||
localStorage.setItem('sammo-game-profile', profile);
|
localStorage.setItem('sammo-game-profile', profile);
|
||||||
@@ -143,13 +150,22 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
|
|||||||
body: await readImage(relativePath),
|
body: await readImage(relativePath),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await page.route('**/game/**', async (route) => {
|
||||||
|
const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/game/')[1] ?? '');
|
||||||
|
const fixturePath = `game/${relativePath}`;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: imageContentType(fixturePath),
|
||||||
|
body: await readImage(fixturePath),
|
||||||
|
});
|
||||||
|
});
|
||||||
await page.route(`**${gameBasePath}/api/trpc/**`, async (route) => {
|
await page.route(`**${gameBasePath}/api/trpc/**`, 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 });
|
||||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
|
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } });
|
||||||
if (operation === 'join.getConfig') return response({});
|
if (operation === 'join.getConfig') return response({});
|
||||||
if (operation === 'general.me') return response(generalContext);
|
if (operation === 'general.me') return response(generalContext);
|
||||||
if (operation === 'world.getMap') return response(map);
|
if (operation === 'world.getMap') return response(mapFixture);
|
||||||
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||||
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
|
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
|
||||||
return response({ turns: [], revision: 0 });
|
return response({ turns: [], revision: 0 });
|
||||||
@@ -250,10 +266,10 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb
|
|||||||
generalCount: 1,
|
generalCount: 1,
|
||||||
cities: ['허창'],
|
cities: ['허창'],
|
||||||
},
|
},
|
||||||
],
|
].slice(0, globalNationCount),
|
||||||
diplomacy: { 1: { 1: 2, 2: 0 }, 2: { 1: 0, 2: 2 } },
|
diplomacy: { 1: { 1: 2, 2: 0 }, 2: { 1: 0, 2: 2 } },
|
||||||
conflict: [],
|
conflict: [],
|
||||||
map,
|
map: mapFixture,
|
||||||
});
|
});
|
||||||
if (operation === 'world.getMapLayout') return response(layout);
|
if (operation === 'world.getMapLayout') return response(layout);
|
||||||
if (operation === 'world.getCurrentCity')
|
if (operation === 'world.getCurrentCity')
|
||||||
@@ -375,6 +391,86 @@ test('global-info renders the ref nation summary columns beside the map', async
|
|||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
await go(page, 'global-info');
|
await go(page, 'global-info');
|
||||||
|
|
||||||
|
const mapTitle = page.locator('.map-title');
|
||||||
|
await expect(mapTitle).toHaveText(/200年 1月/u);
|
||||||
|
const titleBackground = await page
|
||||||
|
.locator('.map-top')
|
||||||
|
.evaluate((element) => getComputedStyle(element).backgroundImage);
|
||||||
|
expect(titleBackground).toContain('ltitle.jpg');
|
||||||
|
expect(titleBackground).toContain('rtitle.jpg');
|
||||||
|
const titleTextBackground = await mapTitle.evaluate((element) => getComputedStyle(element).backgroundImage);
|
||||||
|
expect(titleTextBackground).toContain('ad.gif');
|
||||||
|
expect(titleTextBackground).toContain('spring.gif');
|
||||||
|
await mapTitle.hover();
|
||||||
|
const titleTooltip = page.locator('.map-title-tooltip');
|
||||||
|
await expect(titleTooltip).toBeVisible();
|
||||||
|
await expect(titleTooltip).toContainText('기술등급 제한 : 5등급 (205년 해제)');
|
||||||
|
const titleGeometry = await page.locator('.map-top').evaluate((element) => {
|
||||||
|
const band = element.getBoundingClientRect();
|
||||||
|
const title = element.querySelector('.map-title')?.getBoundingClientRect();
|
||||||
|
const tooltip = element.querySelector('.map-title-tooltip')?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
band: { width: band.width, height: band.height },
|
||||||
|
title: title ? { width: title.width, height: title.height } : null,
|
||||||
|
tooltip: tooltip ? { width: tooltip.width, height: tooltip.height } : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(titleGeometry).toEqual({
|
||||||
|
band: { width: 700, height: 20 },
|
||||||
|
title: { width: 160, height: 20 },
|
||||||
|
tooltip: { width: 220, height: 28 },
|
||||||
|
});
|
||||||
|
if (artifactRoot) {
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-title-hover.png'), fullPage: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoveredCastle = page.locator('.city-base').first();
|
||||||
|
await hoveredCastle.hover();
|
||||||
|
const cityTooltip = page.locator('.map-tooltip');
|
||||||
|
await expect(cityTooltip).toBeVisible();
|
||||||
|
await expect(cityTooltip.locator('.tooltip-title')).toHaveText('【하북|특】업');
|
||||||
|
await expect(cityTooltip.locator('.tooltip-body')).toHaveText('아국');
|
||||||
|
const cityTooltipStyle = await cityTooltip.evaluate((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(cityTooltipStyle.width).toBeGreaterThanOrEqual(120);
|
||||||
|
expect(cityTooltipStyle).toMatchObject({
|
||||||
|
backgroundColor: 'rgb(30, 164, 255)',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '15px',
|
||||||
|
});
|
||||||
|
if (artifactRoot) {
|
||||||
|
await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-city-hover.png'), fullPage: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const [matrixWrapBox, matrixBox] = await Promise.all([
|
||||||
|
page.locator('.matrix-wrap').boundingBox(),
|
||||||
|
page.locator('.matrix').boundingBox(),
|
||||||
|
]);
|
||||||
|
if (!matrixWrapBox || !matrixBox) return null;
|
||||||
|
return Math.abs(matrixWrapBox.height - matrixBox.height);
|
||||||
|
})
|
||||||
|
.toBeLessThan(1);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page
|
||||||
|
.locator('.map-area .city-icon')
|
||||||
|
.evaluateAll((images: HTMLImageElement[]) =>
|
||||||
|
images.every((image) => image.complete && image.naturalWidth > 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
const castleGeometry = await page.locator('.map-area').evaluate((mapArea) => {
|
const castleGeometry = await page.locator('.map-area').evaluate((mapArea) => {
|
||||||
const mapRect = mapArea.getBoundingClientRect();
|
const mapRect = mapArea.getBoundingClientRect();
|
||||||
return Array.from(mapArea.querySelectorAll<HTMLImageElement>('.city-icon')).map((image) => {
|
return Array.from(mapArea.querySelectorAll<HTMLImageElement>('.city-icon')).map((image) => {
|
||||||
@@ -437,6 +533,10 @@ test('global-info renders the ref nation summary columns beside the map', async
|
|||||||
resolve(artifactRoot, 'core-global-info-computed-dom.json'),
|
resolve(artifactRoot, 'core-global-info-computed-dom.json'),
|
||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
{
|
{
|
||||||
|
titleBackground,
|
||||||
|
titleTextBackground,
|
||||||
|
titleGeometry,
|
||||||
|
cityTooltipStyle,
|
||||||
geometry,
|
geometry,
|
||||||
castleGeometry,
|
castleGeometry,
|
||||||
headings: await summary.locator('th').allTextContents(),
|
headings: await summary.locator('th').allTextContents(),
|
||||||
@@ -489,11 +589,101 @@ test('global-info renders the ref nation summary columns beside the map', async
|
|||||||
expect(mobileGeometry.map?.width).toBe(500);
|
expect(mobileGeometry.map?.width).toBe(500);
|
||||||
expect(mobileGeometry.summary?.width).toBe(500);
|
expect(mobileGeometry.summary?.width).toBe(500);
|
||||||
expect(mobileGeometry.summary?.y).toBe(mobileGeometry.map?.bottom);
|
expect(mobileGeometry.summary?.y).toBe(mobileGeometry.map?.bottom);
|
||||||
|
await mapTitle.hover();
|
||||||
|
await expect(titleTooltip).toBeVisible();
|
||||||
|
const mobileTitleGeometry = await page.locator('.map-top').evaluate((element) => {
|
||||||
|
const band = element.getBoundingClientRect();
|
||||||
|
const tooltip = element.querySelector('.map-title-tooltip')?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
band: { width: band.width, height: band.height },
|
||||||
|
tooltip: tooltip ? { x: tooltip.x, width: tooltip.width } : null,
|
||||||
|
documentWidth: document.documentElement.scrollWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(mobileTitleGeometry.band).toEqual({ width: 500, height: 20 });
|
||||||
|
expect(mobileTitleGeometry.tooltip?.width).toBe(220);
|
||||||
|
expect(mobileTitleGeometry.documentWidth).toBe(500);
|
||||||
|
await hoveredCastle.hover();
|
||||||
|
await expect(cityTooltip).toBeVisible();
|
||||||
|
await expect(cityTooltip.locator('.tooltip-title')).toHaveText('【하북|특】업');
|
||||||
if (artifactRoot) {
|
if (artifactRoot) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, 'core-global-info-mobile-city-hover.png'),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-mobile.png'), fullPage: true });
|
await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-mobile.png'), fullPage: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('map title keeps the ref early-game restriction boundary and color', async ({ page }) => {
|
||||||
|
await install(page, 'member', 100, 2, { ...map, startYear: 198 });
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await go(page, 'global-info');
|
||||||
|
|
||||||
|
const mapTitle = page.locator('.map-title');
|
||||||
|
await expect(mapTitle).toHaveCSS('color', 'rgb(255, 255, 0)');
|
||||||
|
await mapTitle.hover();
|
||||||
|
await expect(page.locator('.map-title-tooltip')).toHaveText(
|
||||||
|
'초반제한 기간 : 0년 12개월 (201년)기술등급 제한 : 1등급 (203년 해제)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global-info diplomacy height follows the active nation count', async ({ page }) => {
|
||||||
|
await install(page, 'member', 100, 1);
|
||||||
|
await go(page, 'global-info');
|
||||||
|
|
||||||
|
const matrixWrap = page.locator('.matrix-wrap');
|
||||||
|
const matrix = page.locator('.matrix');
|
||||||
|
const mapSection = page.locator('.map-section');
|
||||||
|
await expect(matrix.locator('tbody tr')).toHaveCount(1);
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ name: 'desktop', width: 1200, height: 900 },
|
||||||
|
{ name: 'mobile', width: 390, height: 844 },
|
||||||
|
]) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const [wrapBox, matrixBox] = await Promise.all([matrixWrap.boundingBox(), matrix.boundingBox()]);
|
||||||
|
if (!wrapBox || !matrixBox) return null;
|
||||||
|
return Math.abs(wrapBox.height - matrixBox.height);
|
||||||
|
})
|
||||||
|
.toBeLessThan(1);
|
||||||
|
|
||||||
|
const geometry = await page.evaluate(() => {
|
||||||
|
const rect = (selector: string) => document.querySelector(selector)?.getBoundingClientRect();
|
||||||
|
const diplomacy = rect('.section');
|
||||||
|
const matrix = rect('.matrix');
|
||||||
|
const matrixWrap = rect('.matrix-wrap');
|
||||||
|
const mapSection = rect('.map-section');
|
||||||
|
return {
|
||||||
|
diplomacyHeight: diplomacy?.height ?? null,
|
||||||
|
matrixHeight: matrix?.height ?? null,
|
||||||
|
matrixWrapHeight: matrixWrap?.height ?? null,
|
||||||
|
gapToMap: matrixWrap && mapSection ? mapSection.top - matrixWrap.bottom : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.matrixHeight).not.toBeNull();
|
||||||
|
expect(geometry.matrixWrapHeight).toBeCloseTo(geometry.matrixHeight!, 0);
|
||||||
|
expect(geometry.diplomacyHeight).toBeLessThan(200);
|
||||||
|
expect(geometry.gapToMap).toBe(21);
|
||||||
|
await expect(mapSection).toBeInViewport();
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
resolve(artifactRoot, `core-global-info-one-nation-${viewport.name}.json`),
|
||||||
|
`${JSON.stringify(geometry, null, 2)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(artifactRoot, `core-global-info-one-nation-${viewport.name}.png`),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('current-city hides values and general rows for a wandering user', async ({ page }) => {
|
test('current-city hides values and general rows for a wandering user', async ({ page }) => {
|
||||||
await install(page, 'wanderer');
|
await install(page, 'wanderer');
|
||||||
await go(page, 'current-city');
|
await go(page, 'current-city');
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type FixtureState = {
|
|||||||
adjustIconInputs?: Array<Record<string, unknown>>;
|
adjustIconInputs?: Array<Record<string, unknown>>;
|
||||||
joinConfig?: Record<string, unknown>;
|
joinConfig?: Record<string, unknown>;
|
||||||
createGeneralInputs?: Array<Record<string, unknown>>;
|
createGeneralInputs?: Array<Record<string, unknown>>;
|
||||||
|
mainTraits?: { personal: string; specialDomestic: string; specialWar: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
type TrpcRequestPayload = {
|
type TrpcRequestPayload = {
|
||||||
@@ -75,7 +76,7 @@ const myGeneral = (state: FixtureState) => ({
|
|||||||
age: 30,
|
age: 30,
|
||||||
turnTime: '2026-01-01 00:10:00',
|
turnTime: '2026-01-01 00:10:00',
|
||||||
crewTypeId: 1,
|
crewTypeId: 1,
|
||||||
traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' },
|
traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' },
|
||||||
progression: {
|
progression: {
|
||||||
experienceLevel: 1,
|
experienceLevel: 1,
|
||||||
dedicationLevel: 2,
|
dedicationLevel: 2,
|
||||||
@@ -86,7 +87,19 @@ const myGeneral = (state: FixtureState) => ({
|
|||||||
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
||||||
},
|
},
|
||||||
city: { id: 1, name: '업', level: 8, nationId: 1 },
|
city: { id: 1, name: '업', level: 8, nationId: 1 },
|
||||||
nation: { id: 1, name: '위', color: '#777777', level: 3 },
|
nation: state.buildNationCandidateEnabled
|
||||||
|
? {
|
||||||
|
id: 0,
|
||||||
|
name: '재야',
|
||||||
|
color: '#000000',
|
||||||
|
level: 0,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
tech: 0,
|
||||||
|
typeCode: 'None',
|
||||||
|
capitalCityId: null,
|
||||||
|
}
|
||||||
|
: { id: 1, name: '위', color: '#777777', level: 3 },
|
||||||
settings: {
|
settings: {
|
||||||
tnmt: 0,
|
tnmt: 0,
|
||||||
defence_train: 80,
|
defence_train: 80,
|
||||||
@@ -221,6 +234,25 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
state.createGeneralInputs?.push(jsonInput);
|
state.createGeneralInputs?.push(jsonInput);
|
||||||
return response({ generalId: 9 });
|
return response({ generalId: 9 });
|
||||||
}
|
}
|
||||||
|
if (operation === 'dashboard.getContextBundleDelta') {
|
||||||
|
return response({
|
||||||
|
context: {
|
||||||
|
kind: 'snapshot',
|
||||||
|
revision: 'AAAAAAAAAAAAAAAAAAAAAA',
|
||||||
|
data: myGeneral(state),
|
||||||
|
},
|
||||||
|
commandTable: {
|
||||||
|
kind: 'snapshot',
|
||||||
|
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
|
||||||
|
data: { general: [], nation: [] },
|
||||||
|
},
|
||||||
|
boardAccess: {
|
||||||
|
kind: 'snapshot',
|
||||||
|
revision: 'CCCCCCCCCCCCCCCCCCCCCC',
|
||||||
|
data: { permission: 4, canMeeting: true, canSecret: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
if (operation === 'general.me') {
|
if (operation === 'general.me') {
|
||||||
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
|
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
|
||||||
return response(myGeneral(state));
|
return response(myGeneral(state));
|
||||||
@@ -437,6 +469,49 @@ test('정화된 국가 방침은 실행 가능한 속성 없이 Chromium에 표
|
|||||||
.toBeUndefined();
|
.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표시한다', async ({ page }) => {
|
||||||
|
const state: FixtureState = {
|
||||||
|
permission: 'member',
|
||||||
|
myset: 0,
|
||||||
|
buildNationCandidateEnabled: true,
|
||||||
|
mainTraits: { personal: '안전', specialDomestic: '상재', specialWar: '신산' },
|
||||||
|
settingMutations: [],
|
||||||
|
accessPages: [],
|
||||||
|
};
|
||||||
|
await install(page, state);
|
||||||
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
|
await page.goto('');
|
||||||
|
|
||||||
|
const nationCard = page.locator('.nation-card');
|
||||||
|
await expect(nationCard.locator('.title')).toHaveText('재야');
|
||||||
|
await expect(nationCard.locator('.empty')).toHaveCount(0);
|
||||||
|
await expect(nationCard.locator('.grid strong')).toHaveText(Array.from({ length: 6 }, () => '해당 없음'));
|
||||||
|
|
||||||
|
const generalCard = page.locator('.general-card');
|
||||||
|
await expect(generalCard).toContainText('성격안전');
|
||||||
|
await expect(generalCard).toContainText('전투특기신산');
|
||||||
|
await expect(generalCard).toContainText('내정특기상재');
|
||||||
|
await expect(generalCard).not.toContainText('che_');
|
||||||
|
|
||||||
|
const geometry = await nationCard.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const title = element.querySelector<HTMLElement>('.title')!;
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
titleBackground: getComputedStyle(title).backgroundColor,
|
||||||
|
placeholderCount: [...element.querySelectorAll('.grid strong')].filter(
|
||||||
|
(cell) => cell.textContent?.trim() === '해당 없음'
|
||||||
|
).length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.width).toBeGreaterThan(0);
|
||||||
|
expect(geometry.height).toBeGreaterThan(0);
|
||||||
|
expect(geometry.titleBackground).toBe('rgb(0, 0, 0)');
|
||||||
|
expect(geometry.placeholderCount).toBe(6);
|
||||||
|
await persistParityArtifact(page, 'main-neutral-trait-display', geometry);
|
||||||
|
});
|
||||||
|
|
||||||
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
|
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
|
||||||
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
|
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
|
||||||
await install(page, state);
|
await install(page, state);
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ type NavigationFixture = {
|
|||||||
refreshDelayMs?: number;
|
refreshDelayMs?: number;
|
||||||
largeCommandTable?: boolean;
|
largeCommandTable?: boolean;
|
||||||
reservedTurns?: Array<{ index: number; action: string; args: Record<string, unknown> }>;
|
reservedTurns?: Array<{ index: number; action: string; args: Record<string, unknown> }>;
|
||||||
|
messages?: unknown;
|
||||||
|
messageContacts?: unknown;
|
||||||
|
autorunLimit?: number | null;
|
||||||
dashboardResponses?: Array<{
|
dashboardResponses?: Array<{
|
||||||
bytes: number;
|
bytes: number;
|
||||||
contextKind: string | null;
|
contextKind: string | null;
|
||||||
@@ -403,10 +406,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
}
|
}
|
||||||
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||||
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
|
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
|
||||||
return response({ turns: state.reservedTurns ?? [], revision: 0 });
|
return response({
|
||||||
|
turns: state.reservedTurns ?? [],
|
||||||
|
revision: 0,
|
||||||
|
autorunLimit: state.autorunLimit ?? null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (operation === 'messages.getRecent') return response(emptyMessages(state.permission));
|
if (operation === 'messages.getRecent') {
|
||||||
if (operation === 'messages.getContacts') return response({ nation: [] });
|
return response(state.messages ?? emptyMessages(state.permission));
|
||||||
|
}
|
||||||
|
if (operation === 'messages.getContacts') return response(state.messageContacts ?? { nation: [] });
|
||||||
if (operation === 'general.getRecentRecords') {
|
if (operation === 'general.getRecentRecords') {
|
||||||
return response({
|
return response({
|
||||||
global: [{ id: 3, text: '장수 동향 기록' }],
|
global: [{ id: 3, text: '장수 동향 기록' }],
|
||||||
@@ -617,7 +626,23 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
|||||||
await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/);
|
await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/);
|
||||||
|
|
||||||
const gameInfoButton = global.locator('[data-menu-id="game-info"]');
|
const gameInfoButton = global.locator('[data-menu-id="game-info"]');
|
||||||
|
const bettingButton = global.locator('[data-navigation-id="nation-betting"]');
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
bettingButton.evaluate((element) => ({
|
||||||
|
backgroundColor: getComputedStyle(element).backgroundColor,
|
||||||
|
backgroundImage: getComputedStyle(element).backgroundImage,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.toEqual({ backgroundColor: 'rgb(0, 88, 44)', backgroundImage: 'none' });
|
||||||
|
await bettingButton.hover();
|
||||||
|
await expect
|
||||||
|
.poll(() => bettingButton.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||||
|
.toBe('rgb(0, 88, 44)');
|
||||||
await gameInfoButton.focus();
|
await gameInfoButton.focus();
|
||||||
|
await expect
|
||||||
|
.poll(() => gameInfoButton.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||||
|
.toBe('rgb(0, 88, 44)');
|
||||||
await gameInfoButton.press('Enter');
|
await gameInfoButton.press('Enter');
|
||||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true');
|
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true');
|
||||||
await expect(global.locator('#global-menu-game-info')).toBeVisible();
|
await expect(global.locator('#global-menu-game-info')).toBeVisible();
|
||||||
@@ -631,6 +656,74 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
|||||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pure NPC message senders are not rendered as reply targets', async ({ page }) => {
|
||||||
|
const target = (generalId: number, generalName: string) => ({
|
||||||
|
generalId,
|
||||||
|
generalName,
|
||||||
|
nationId: 1,
|
||||||
|
nationName: '위',
|
||||||
|
color: '#008000',
|
||||||
|
icon: '',
|
||||||
|
});
|
||||||
|
const messages = {
|
||||||
|
...emptyMessages(0),
|
||||||
|
public: [
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
text: 'NPC 메시지',
|
||||||
|
time: '2026-08-12 12:00:00',
|
||||||
|
msgType: 'public',
|
||||||
|
src: target(22, '순수NPC'),
|
||||||
|
dest: null,
|
||||||
|
option: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
text: '유저 메시지',
|
||||||
|
time: '2026-08-12 11:59:00',
|
||||||
|
msgType: 'public',
|
||||||
|
src: target(21, '유저장수'),
|
||||||
|
dest: null,
|
||||||
|
option: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 1,
|
||||||
|
permission: 0,
|
||||||
|
nationLevel: 1,
|
||||||
|
stage: 0,
|
||||||
|
npcMode: 1,
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
messages,
|
||||||
|
messageContacts: {
|
||||||
|
nation: [
|
||||||
|
{
|
||||||
|
nationId: 1,
|
||||||
|
mailbox: 9001,
|
||||||
|
name: '위',
|
||||||
|
color: '#008000',
|
||||||
|
general: [[21, '유저장수', 0]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await waitForMain(page);
|
||||||
|
|
||||||
|
const npcMessage = page.locator('.desktop-message-panel .msg-plate[data-id="102"]');
|
||||||
|
const userMessage = page.locator('.desktop-message-panel .msg-plate[data-id="101"]');
|
||||||
|
await expect(npcMessage.locator('.msg-header')).toContainText('순수NPC:위');
|
||||||
|
await expect(npcMessage.locator('.msg-header')).not.toContainText('↩');
|
||||||
|
await expect(npcMessage.getByRole('button', { name: /순수NPC/ })).toHaveCount(0);
|
||||||
|
await expect(userMessage.getByRole('button', { name: /유저장수:위.*↩/ })).toBeVisible();
|
||||||
|
await userMessage.getByRole('button', { name: /유저장수:위.*↩/ }).click();
|
||||||
|
await expect(page.locator('.desktop-message-panel #mailbox_list')).toHaveValue('21');
|
||||||
|
await persistArtifact(page, `${basePath.slice(1)}-npc-reply-targets-desktop-1200`);
|
||||||
|
});
|
||||||
|
|
||||||
test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => {
|
test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => {
|
||||||
const state: NavigationFixture = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 1,
|
officerLevel: 1,
|
||||||
@@ -641,11 +734,12 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
generalMeCalls: 0,
|
generalMeCalls: 0,
|
||||||
operations: [],
|
operations: [],
|
||||||
largeCommandTable: true,
|
largeCommandTable: true,
|
||||||
reservedTurns: Array.from({ length: 14 }, (_, index) => ({
|
reservedTurns: Array.from({ length: 30 }, (_, index) => ({
|
||||||
index,
|
index,
|
||||||
action: `command-${index}`,
|
action: index === 0 ? '휴식' : `command-${index}`,
|
||||||
args: {},
|
args: {},
|
||||||
})),
|
})),
|
||||||
|
autorunLimit: 2224,
|
||||||
};
|
};
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
@@ -728,6 +822,27 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
expect(new Set(desktopGeometry.visibleControlBoxes.map(({ y }) => y)).size).toBe(1);
|
expect(new Set(desktopGeometry.visibleControlBoxes.map(({ y }) => y)).size).toBe(1);
|
||||||
expect(desktopGeometry.bottomActionBoxes).toHaveLength(3);
|
expect(desktopGeometry.bottomActionBoxes).toHaveLength(3);
|
||||||
expect(new Set(desktopGeometry.bottomActionBoxes.map(({ y }) => y)).size).toBe(1);
|
expect(new Set(desktopGeometry.bottomActionBoxes.map(({ y }) => y)).size).toBe(1);
|
||||||
|
await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(15);
|
||||||
|
const autonomousRest = page.locator('[data-main-target="commands"] .action-column > div').first();
|
||||||
|
await expect(autonomousRest).toContainText('휴식(자율 행동)');
|
||||||
|
expect(await autonomousRest.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)');
|
||||||
|
|
||||||
|
const tenthTurnButton = page.getByRole('button', { name: '10턴 명령 입력' });
|
||||||
|
await tenthTurnButton.click();
|
||||||
|
const quickPicker = page.getByTestId('command-picker');
|
||||||
|
await expect(quickPicker).toBeVisible();
|
||||||
|
const quickPickerAlignment = await quickPicker.evaluate((element) => {
|
||||||
|
const row = element
|
||||||
|
.closest('.reserved-command-editor')
|
||||||
|
?.querySelector<HTMLElement>('.action-column > div:nth-child(10)');
|
||||||
|
if (!row) throw new Error('10th command row missing');
|
||||||
|
return {
|
||||||
|
pickerTop: element.getBoundingClientRect().top,
|
||||||
|
rowTop: row.getBoundingClientRect().top,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(quickPickerAlignment.pickerTop - quickPickerAlignment.rowTop).toBeCloseTo(30, 0);
|
||||||
|
await quickPicker.getByRole('button', { name: '명령 입력 닫기' }).click();
|
||||||
|
|
||||||
const modeButton = page.locator('[data-main-target="commands"] .control-pad').getByRole('button', {
|
const modeButton = page.locator('[data-main-target="commands"] .control-pad').getByRole('button', {
|
||||||
name: '고급 모드',
|
name: '고급 모드',
|
||||||
@@ -736,6 +851,27 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
await modeButton.focus();
|
await modeButton.focus();
|
||||||
await expect(modeButton).toBeFocused();
|
await expect(modeButton).toBeFocused();
|
||||||
await modeButton.click();
|
await modeButton.click();
|
||||||
|
const advancedControlGeometry = await page.locator('[data-main-target="commands"] .reserved-command-editor').evaluate(
|
||||||
|
(editor) => {
|
||||||
|
const range = editor.querySelector<HTMLElement>('.range-menu');
|
||||||
|
const recent = [...editor.querySelectorAll<HTMLElement>('.control-pad summary')].find((element) =>
|
||||||
|
element.textContent?.includes('최근 실행')
|
||||||
|
);
|
||||||
|
const advanced = editor.querySelector<HTMLElement>('.advanced-actions');
|
||||||
|
const queue = editor.querySelector<HTMLElement>('.queue-grid');
|
||||||
|
if (!range || !recent || !advanced || !queue) throw new Error('advanced command controls missing');
|
||||||
|
return {
|
||||||
|
rangeTop: range.getBoundingClientRect().top,
|
||||||
|
recentTop: recent.getBoundingClientRect().top,
|
||||||
|
advancedTop: advanced.getBoundingClientRect().top,
|
||||||
|
advancedBottom: advanced.getBoundingClientRect().bottom,
|
||||||
|
queueTop: queue.getBoundingClientRect().top,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop);
|
||||||
|
expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop);
|
||||||
|
expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop);
|
||||||
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();
|
||||||
@@ -765,6 +901,32 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
expect(pickerGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
expect(pickerGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||||||
await picker.getByRole('button', { name: '명령 입력 닫기' }).click();
|
await picker.getByRole('button', { name: '명령 입력 닫기' }).click();
|
||||||
|
|
||||||
|
await page.locator('[data-main-target="commands"] .control-pad').getByRole('button', { name: '일반 모드' }).click();
|
||||||
|
const collapsedPanelHeight = await page
|
||||||
|
.locator('[data-main-target="commands"]')
|
||||||
|
.evaluate((element) => element.getBoundingClientRect().height);
|
||||||
|
await page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }).click();
|
||||||
|
await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30);
|
||||||
|
const expandedDesktopGeometry = await page.locator('.layout-desktop').evaluate((layout) => {
|
||||||
|
const commands = layout.querySelector<HTMLElement>('[data-main-target="commands"]');
|
||||||
|
const city = layout.querySelector<HTMLElement>('[data-main-target="city"]');
|
||||||
|
const nation = layout.querySelector<HTMLElement>('[data-main-target="nation"]');
|
||||||
|
if (!commands || !city || !nation) throw new Error('expanded desktop panels missing');
|
||||||
|
return {
|
||||||
|
commandHeight: commands.getBoundingClientRect().height,
|
||||||
|
commandBottom: commands.getBoundingClientRect().bottom,
|
||||||
|
cityBottom: city.getBoundingClientRect().bottom,
|
||||||
|
nationTop: nation.getBoundingClientRect().top,
|
||||||
|
verticalOverflow: commands.scrollHeight - commands.clientHeight,
|
||||||
|
overflowY: getComputedStyle(commands).overflowY,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(expandedDesktopGeometry.commandHeight).toBeGreaterThan(collapsedPanelHeight);
|
||||||
|
expect(expandedDesktopGeometry.verticalOverflow).toBeLessThanOrEqual(0);
|
||||||
|
expect(expandedDesktopGeometry.overflowY).toBe('visible');
|
||||||
|
expect(expandedDesktopGeometry.cityBottom).toBe(expandedDesktopGeometry.commandBottom);
|
||||||
|
expect(expandedDesktopGeometry.nationTop).toBeGreaterThanOrEqual(expandedDesktopGeometry.commandBottom);
|
||||||
|
|
||||||
const captureProgress = async (name: string) => {
|
const captureProgress = async (name: string) => {
|
||||||
if (!artifactRoot) return;
|
if (!artifactRoot) return;
|
||||||
await mkdir(artifactRoot, { recursive: true });
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
@@ -852,9 +1014,9 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
};
|
};
|
||||||
await captureProgress('desktop-1200');
|
await captureProgress('desktop-1200');
|
||||||
|
|
||||||
await page.locator('[data-main-target="commands"] .control-pad').getByRole('button', { name: '일반 모드' }).click();
|
|
||||||
await page.setViewportSize({ width: 500, height: 900 });
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
await expect(page.locator('.layout-mobile')).toBeVisible();
|
await expect(page.locator('.layout-mobile')).toBeVisible();
|
||||||
|
await page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }).click();
|
||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
|
||||||
await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8);
|
await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8);
|
||||||
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
|
await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4);
|
||||||
@@ -889,7 +1051,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
controlBoxes,
|
controlBoxes,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(mobileGeometry.panelHeight).toBe(645);
|
expect(mobileGeometry.panelHeight).toBeGreaterThan(645);
|
||||||
expect(mobileGeometry.editorLeft).toBeGreaterThanOrEqual(mobileGeometry.panelLeft);
|
expect(mobileGeometry.editorLeft).toBeGreaterThanOrEqual(mobileGeometry.panelLeft);
|
||||||
expect(mobileGeometry.editorRight).toBeLessThanOrEqual(mobileGeometry.panelRight);
|
expect(mobileGeometry.editorRight).toBeLessThanOrEqual(mobileGeometry.panelRight);
|
||||||
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||||||
@@ -897,6 +1059,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
|||||||
expect(mobileGeometry.controlColumns.split(' ')).toHaveLength(3);
|
expect(mobileGeometry.controlColumns.split(' ')).toHaveLength(3);
|
||||||
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 captureProgress('mobile-500');
|
await captureProgress('mobile-500');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -925,6 +1088,15 @@ 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
|
||||||
|
.poll(() =>
|
||||||
|
page
|
||||||
|
.locator('.main-global-menu')
|
||||||
|
.first()
|
||||||
|
.locator('[data-navigation-id="nation-betting"]')
|
||||||
|
.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||||
|
)
|
||||||
|
.toBe('rgb(0, 88, 44)');
|
||||||
const documentGeometry = await page.locator('.main-page').evaluate((element) => {
|
const documentGeometry = await page.locator('.main-page').evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const commandArgsValid = ref(false);
|
|||||||
const expanded = ref(false);
|
const expanded = ref(false);
|
||||||
const menuRevision = ref(0);
|
const menuRevision = ref(0);
|
||||||
const pendingReservation = ref<CommandPatternEntry | null>(null);
|
const pendingReservation = ref<CommandPatternEntry | null>(null);
|
||||||
|
const collapsedRowCount = 15;
|
||||||
|
|
||||||
const loadStorage = (key: string) => {
|
const loadStorage = (key: string) => {
|
||||||
storage.value = new CommandStorage(key);
|
storage.value = new CommandStorage(key);
|
||||||
@@ -108,7 +109,11 @@ const labelMap = computed(() => {
|
|||||||
for (const group of groups) for (const command of group.values) map.set(command.key, command.name);
|
for (const group of groups) for (const command of group.values) map.set(command.key, command.name);
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
const displayRows = computed(() => props.rows.slice(0, expanded.value || props.compact ? props.rows.length : 14));
|
const displayRows = computed(() =>
|
||||||
|
props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount)
|
||||||
|
);
|
||||||
|
const quickPickerTop = computed(() => `${70 + (quickTarget.value ?? 0) * 34.4}px`);
|
||||||
|
const rowLabel = (row: ReservedCommandRow): string => row.label ?? labelMap.value.get(row.action) ?? row.action;
|
||||||
const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length);
|
const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length);
|
||||||
const pattern = () => extractPattern(props.rows, selectedIndices());
|
const pattern = () => extractPattern(props.rows, selectedIndices());
|
||||||
const touchMenus = () => (menuRevision.value += 1);
|
const touchMenus = () => (menuRevision.value += 1);
|
||||||
@@ -396,6 +401,87 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
</details>
|
</details>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<div v-if="editMode" class="advanced-actions">
|
||||||
|
<details class="legacy-menu selected-menu">
|
||||||
|
<summary>선택한 턴을</summary>
|
||||||
|
<div class="menu-items">
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
cut();
|
||||||
|
clickOutsideMenu($event);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
잘라내기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
copy();
|
||||||
|
clickOutsideMenu($event);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
복사하기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
paste();
|
||||||
|
clickOutsideMenu($event);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
붙여넣기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
textCopy();
|
||||||
|
clickOutsideMenu($event);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
텍스트 복사
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
saveTemplate();
|
||||||
|
clickOutsideMenu($event);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
보관하기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
repeatPattern();
|
||||||
|
clickOutsideMenu($event);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
반복하기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
clearSelection();
|
||||||
|
clickOutsideMenu($event);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
비우기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
rearrange('pull');
|
||||||
|
clickOutsideMenu($event);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
지우고 당기기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
rearrange('push');
|
||||||
|
clickOutsideMenu($event);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
뒤로 밀기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<button type="button" class="select-command" @click="openPicker()">명령 선택 ▾</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="queue-area">
|
<div class="queue-area">
|
||||||
<div class="queue-grid" :class="{ advanced: editMode }">
|
<div class="queue-grid" :class="{ advanced: editMode }">
|
||||||
<DragSelect
|
<DragSelect
|
||||||
@@ -443,9 +529,11 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
<div
|
<div
|
||||||
v-for="row in displayRows"
|
v-for="row in displayRows"
|
||||||
:key="row.index"
|
:key="row.index"
|
||||||
:title="row.label ?? labelMap.get(row.action) ?? row.action"
|
:title="row.autonomous ? `${rowLabel(row)} · 자율 행동` : rowLabel(row)"
|
||||||
|
:class="{ autonomous: row.autonomous }"
|
||||||
>
|
>
|
||||||
{{ row.label ?? labelMap.get(row.action) ?? row.action }}
|
<span>{{ rowLabel(row) }}</span>
|
||||||
|
<small v-if="row.autonomous && row.action === '휴식'">(자율 행동)</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!editMode" class="edit-column">
|
<div v-if="!editMode" class="edit-column">
|
||||||
@@ -461,87 +549,6 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="editMode" class="advanced-actions">
|
|
||||||
<details class="legacy-menu selected-menu">
|
|
||||||
<summary>선택한 턴을</summary>
|
|
||||||
<div class="menu-items">
|
|
||||||
<button
|
|
||||||
@click="
|
|
||||||
cut();
|
|
||||||
clickOutsideMenu($event);
|
|
||||||
"
|
|
||||||
>
|
|
||||||
잘라내기
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="
|
|
||||||
copy();
|
|
||||||
clickOutsideMenu($event);
|
|
||||||
"
|
|
||||||
>
|
|
||||||
복사하기
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="
|
|
||||||
paste();
|
|
||||||
clickOutsideMenu($event);
|
|
||||||
"
|
|
||||||
>
|
|
||||||
붙여넣기
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="
|
|
||||||
textCopy();
|
|
||||||
clickOutsideMenu($event);
|
|
||||||
"
|
|
||||||
>
|
|
||||||
텍스트 복사
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="
|
|
||||||
saveTemplate();
|
|
||||||
clickOutsideMenu($event);
|
|
||||||
"
|
|
||||||
>
|
|
||||||
보관하기
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="
|
|
||||||
repeatPattern();
|
|
||||||
clickOutsideMenu($event);
|
|
||||||
"
|
|
||||||
>
|
|
||||||
반복하기
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="
|
|
||||||
clearSelection();
|
|
||||||
clickOutsideMenu($event);
|
|
||||||
"
|
|
||||||
>
|
|
||||||
비우기
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="
|
|
||||||
rearrange('pull');
|
|
||||||
clickOutsideMenu($event);
|
|
||||||
"
|
|
||||||
>
|
|
||||||
지우고 당기기
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="
|
|
||||||
rearrange('push');
|
|
||||||
clickOutsideMenu($event);
|
|
||||||
"
|
|
||||||
>
|
|
||||||
뒤로 밀기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
<button type="button" class="select-command" @click="openPicker()">명령 선택 ▾</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!props.compact" class="bottom-actions">
|
<div v-if="!props.compact" class="bottom-actions">
|
||||||
<button type="button" @click="emit('shift', -1)">당기기</button>
|
<button type="button" @click="emit('shift', -1)">당기기</button>
|
||||||
<button type="button" @click="emit('shift', 1)">미루기</button>
|
<button type="button" @click="emit('shift', 1)">미루기</button>
|
||||||
@@ -550,7 +557,12 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="pickerOpen" class="command-picker" data-testid="command-picker">
|
<div
|
||||||
|
v-if="pickerOpen"
|
||||||
|
class="command-picker"
|
||||||
|
data-testid="command-picker"
|
||||||
|
:style="quickTarget === null || props.compact ? undefined : { top: quickPickerTop }"
|
||||||
|
>
|
||||||
<header>
|
<header>
|
||||||
<strong>{{ quickTarget === null ? '선택한 턴' : `${quickTarget + 1}턴` }} 명령 입력</strong
|
<strong>{{ quickTarget === null ? '선택한 턴' : `${quickTarget + 1}턴` }} 명령 입력</strong
|
||||||
><button type="button" aria-label="명령 입력 닫기" @click="closePicker">×</button>
|
><button type="button" aria-label="명령 입력 닫기" @click="closePicker">×</button>
|
||||||
@@ -628,6 +640,9 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 3px 0;
|
padding: 3px 0;
|
||||||
}
|
}
|
||||||
|
.queue-area {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
.control-pad > button,
|
.control-pad > button,
|
||||||
.clock,
|
.clock,
|
||||||
.legacy-menu > summary,
|
.legacy-menu > summary,
|
||||||
@@ -779,6 +794,13 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
.action-column > div:nth-child(even) {
|
.action-column > div:nth-child(even) {
|
||||||
background: #071638;
|
background: #071638;
|
||||||
}
|
}
|
||||||
|
.action-column > div.autonomous {
|
||||||
|
color: #aaffff;
|
||||||
|
}
|
||||||
|
.action-column small {
|
||||||
|
font-size: 0.72em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
.edit-column button {
|
.edit-column button {
|
||||||
background: #444;
|
background: #444;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -786,6 +808,7 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
.advanced-actions {
|
.advanced-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 5fr 7fr;
|
grid-template-columns: 5fr 7fr;
|
||||||
|
order: 1;
|
||||||
}
|
}
|
||||||
.advanced-actions > * {
|
.advanced-actions > * {
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
@@ -808,6 +831,10 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
background: #303030;
|
background: #303030;
|
||||||
box-shadow: 0 6px 16px #000;
|
box-shadow: 0 6px 16px #000;
|
||||||
}
|
}
|
||||||
|
.reserved-command-editor:not(.compact) .command-picker {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
.command-picker > header {
|
.command-picker > header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export type ReservedCommandRow = {
|
|||||||
time?: string;
|
time?: string;
|
||||||
year?: number;
|
year?: number;
|
||||||
month?: number;
|
month?: number;
|
||||||
|
autonomous?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandPatternEntry = {
|
export type CommandPatternEntry = {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const props = defineProps<{
|
|||||||
currentYear?: number;
|
currentYear?: number;
|
||||||
currentMonth?: number;
|
currentMonth?: number;
|
||||||
turnTermMinutes?: number;
|
turnTermMinutes?: number;
|
||||||
|
autorunLimit?: number | null;
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ const rows = computed<ReservedCommandRow[]>(() => {
|
|||||||
label: labelMap.value.get(turn.action) ?? turn.action,
|
label: labelMap.value.get(turn.action) ?? turn.action,
|
||||||
year: Math.floor(absoluteMonth / 12),
|
year: Math.floor(absoluteMonth / 12),
|
||||||
month: (absoluteMonth % 12) + 1,
|
month: (absoluteMonth % 12) + 1,
|
||||||
|
autonomous: props.autorunLimit != null && absoluteMonth <= props.autorunLimit - 1,
|
||||||
time: date
|
time: date
|
||||||
? term >= 5
|
? term >= 5
|
||||||
? `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`
|
? `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`
|
||||||
|
|||||||
@@ -115,6 +115,33 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-global-menu > :deep(.main-menu-link),
|
||||||
|
.main-menu-popup > .main-menu-button,
|
||||||
|
.main-menu-split > :deep(.main-menu-link),
|
||||||
|
.main-menu-split > .main-menu-split__toggle {
|
||||||
|
border-color: var(--sammo-button-navigation-border);
|
||||||
|
background-color: var(--sammo-button-navigation-bg);
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-global-menu > :deep(.main-menu-link:hover),
|
||||||
|
.main-global-menu > :deep(.main-menu-link:focus-visible),
|
||||||
|
.main-global-menu > :deep(.main-menu-link:active),
|
||||||
|
.main-menu-popup > .main-menu-button:hover,
|
||||||
|
.main-menu-popup > .main-menu-button:focus-visible,
|
||||||
|
.main-menu-popup > .main-menu-button:active,
|
||||||
|
.main-menu-popup > .main-menu-button[aria-expanded='true'],
|
||||||
|
.main-menu-split > :deep(.main-menu-link:hover),
|
||||||
|
.main-menu-split > :deep(.main-menu-link:focus-visible),
|
||||||
|
.main-menu-split > :deep(.main-menu-link:active),
|
||||||
|
.main-menu-split > .main-menu-split__toggle:hover,
|
||||||
|
.main-menu-split > .main-menu-split__toggle:focus-visible,
|
||||||
|
.main-menu-split > .main-menu-split__toggle:active,
|
||||||
|
.main-menu-split > .main-menu-split__toggle[aria-expanded='true'] {
|
||||||
|
border-color: var(--sammo-button-navigation-border);
|
||||||
|
background-color: var(--sammo-button-navigation-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.main-menu-popup > .main-menu-button,
|
.main-menu-popup > .main-menu-button,
|
||||||
.main-menu-split > :deep(.main-menu-link) {
|
.main-menu-split > :deep(.main-menu-link) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ interface MapSummary {
|
|||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
startYear: number;
|
startYear: number;
|
||||||
|
techLevelLimit?: {
|
||||||
|
maxLevel: number;
|
||||||
|
initialLevel: number;
|
||||||
|
increaseYears: number;
|
||||||
|
};
|
||||||
cityList: [number, number, number, number, number, number][];
|
cityList: [number, number, number, number, number, number][];
|
||||||
nationList: [number, string, string, number][];
|
nationList: [number, string, string, number][];
|
||||||
myCity?: number | null;
|
myCity?: number | null;
|
||||||
@@ -198,9 +203,77 @@ const mapSummary = computed(() => {
|
|||||||
if (!props.mapData) {
|
if (!props.mapData) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return `${props.mapData.year}년 ${props.mapData.month}월`;
|
return `${props.mapData.year}年 ${props.mapData.month}月`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const titleColor = computed(() => {
|
||||||
|
if (!props.mapData) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const { startYear, year } = props.mapData;
|
||||||
|
if (year < startYear + 1) {
|
||||||
|
return 'magenta';
|
||||||
|
}
|
||||||
|
if (year < startYear + 2) {
|
||||||
|
return 'orange';
|
||||||
|
}
|
||||||
|
if (year < startYear + 3) {
|
||||||
|
return 'yellow';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleTooltipLines = computed(() => {
|
||||||
|
if (!props.mapData) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startYear, year, month } = props.mapData;
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (year <= startYear + 3) {
|
||||||
|
// Ref uses joinYearMonth(startYear + 3, 0) as the limit boundary.
|
||||||
|
const remainingMonths = (startYear + 3) * 12 - 1 - (year * 12 + month - 1);
|
||||||
|
const remainYear = Math.trunc(remainingMonths / 12);
|
||||||
|
const remainMonth = (remainingMonths % 12) + 1;
|
||||||
|
lines.push(
|
||||||
|
`초반제한 기간 : ${remainYear}년${remainMonth > 0 ? ` ${remainMonth}개월` : ''} (${startYear + 3}년)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = props.mapData.techLevelLimit ?? {
|
||||||
|
maxLevel: 12,
|
||||||
|
initialLevel: 1,
|
||||||
|
increaseYears: 5,
|
||||||
|
};
|
||||||
|
const currentLevel = Math.min(
|
||||||
|
limit.maxLevel,
|
||||||
|
Math.max(1, Math.floor((year - startYear) / limit.increaseYears) + limit.initialLevel)
|
||||||
|
);
|
||||||
|
if (currentLevel === limit.maxLevel) {
|
||||||
|
lines.push(`기술등급 제한 : ${currentLevel}등급 (최종)`);
|
||||||
|
} else {
|
||||||
|
lines.push(`기술등급 제한 : ${currentLevel}등급 (${currentLevel * limit.increaseYears + startYear}년 해제)`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleBandStyle = computed(() =>
|
||||||
|
detailMode.value
|
||||||
|
? {
|
||||||
|
backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleTextStyle = computed(() =>
|
||||||
|
detailMode.value
|
||||||
|
? {
|
||||||
|
color: titleColor.value,
|
||||||
|
backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`,
|
||||||
|
}
|
||||||
|
: { color: titleColor.value }
|
||||||
|
);
|
||||||
|
|
||||||
const mapThemeClass = computed(() => {
|
const mapThemeClass = computed(() => {
|
||||||
return `map-theme-${mapTheme.value}`;
|
return `map-theme-${mapTheme.value}`;
|
||||||
});
|
});
|
||||||
@@ -269,6 +342,24 @@ const hoveredCity = computed(() => {
|
|||||||
return cityViews.value.find((city) => city.id === hoveredCityId.value) ?? null;
|
return cityViews.value.find((city) => city.id === hoveredCityId.value) ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hoveredCityTitle = computed(() => {
|
||||||
|
if (!hoveredCity.value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `【${hoveredCity.value.regionName}|${hoveredCity.value.levelName}】${hoveredCity.value.name}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tooltipPosition = computed(() => {
|
||||||
|
const width = 120;
|
||||||
|
const offset = 10;
|
||||||
|
const mapPixelWidth = BASE_MAP_WIDTH * mapScale.value;
|
||||||
|
const left = elementX.value + width + offset > mapPixelWidth ? elementX.value - width - 5 : elementX.value + offset;
|
||||||
|
return {
|
||||||
|
left: `${Math.max(0, left)}px`,
|
||||||
|
top: `${elementY.value + 30}px`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const setHoveredCity = (cityId: number | null) => {
|
const setHoveredCity = (cityId: number | null) => {
|
||||||
mapStore.setHoveredCity(cityId);
|
mapStore.setHoveredCity(cityId);
|
||||||
};
|
};
|
||||||
@@ -280,8 +371,13 @@ const selectCity = (cityId: number) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="map-viewer">
|
<div class="map-viewer">
|
||||||
<div class="map-top">
|
<div class="map-top" :style="titleBandStyle">
|
||||||
<div class="map-title">{{ mapSummary }}</div>
|
<div class="map-title" tabindex="0" :style="titleTextStyle">
|
||||||
|
{{ mapSummary }}
|
||||||
|
<div class="map-title-tooltip" role="tooltip">
|
||||||
|
<div v-for="line in titleTooltipLines" :key="line">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="props.loading">
|
<div v-if="props.loading">
|
||||||
<SkeletonLines :lines="4" />
|
<SkeletonLines :lines="4" />
|
||||||
@@ -309,15 +405,9 @@ const selectCity = (cityId: number) => {
|
|||||||
@leave="setHoveredCity(null)"
|
@leave="setHoveredCity(null)"
|
||||||
@select="selectCity"
|
@select="selectCity"
|
||||||
/>
|
/>
|
||||||
<div
|
<div v-if="hoveredCity" class="map-tooltip" :style="tooltipPosition">
|
||||||
v-if="hoveredCity"
|
<div class="tooltip-title">{{ hoveredCityTitle }}</div>
|
||||||
class="map-tooltip"
|
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
|
||||||
:style="{ left: `${elementX + 16}px`, top: `${elementY + 16}px` }"
|
|
||||||
>
|
|
||||||
<div class="tooltip-title">{{ hoveredCity.name }}</div>
|
|
||||||
<div class="tooltip-body">
|
|
||||||
{{ hoveredCity.nationName }} · {{ hoveredCity.regionName }} · {{ hoveredCity.levelName }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="map-controls">
|
<div class="map-controls">
|
||||||
<button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
|
<button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
|
||||||
@@ -338,17 +428,69 @@ const selectCity = (cityId: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.map-top {
|
.map-top {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #111;
|
background: #111;
|
||||||
|
background-position:
|
||||||
|
left top,
|
||||||
|
right top;
|
||||||
|
background-repeat: no-repeat;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-title {
|
.map-title {
|
||||||
font-size: 0.95rem;
|
position: relative;
|
||||||
font-weight: 600;
|
display: block;
|
||||||
|
width: 160px;
|
||||||
|
height: 20px;
|
||||||
|
margin: auto;
|
||||||
|
background-position:
|
||||||
|
left top,
|
||||||
|
right top;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-title-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
bottom: calc(100% + 7px);
|
||||||
|
left: 50%;
|
||||||
|
display: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 220px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: left;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-title-tooltip::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
border-top-color: #000;
|
||||||
|
content: '';
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-title:hover .map-title-tooltip,
|
||||||
|
.map-title:focus .map-title-tooltip,
|
||||||
|
.map-title:focus-within .map-title-tooltip {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-controls {
|
.map-controls {
|
||||||
@@ -400,19 +542,28 @@ const selectCity = (cityId: number) => {
|
|||||||
|
|
||||||
.map-tooltip {
|
.map-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 16;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 120px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
border: 1px solid gray;
|
||||||
background: rgba(16, 16, 16, 0.9);
|
padding: 0;
|
||||||
padding: 4px 6px;
|
background: rgb(30, 164, 255);
|
||||||
font-size: 0.65rem;
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 15px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-title {
|
.tooltip-title {
|
||||||
font-weight: 600;
|
height: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-body {
|
.tooltip-body {
|
||||||
color: rgba(232, 221, 196, 0.6);
|
height: 15px;
|
||||||
|
border-top: 1px solid gray;
|
||||||
|
color: #fff;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-empty {
|
.map-empty {
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ const bucket = (type: MessageType): MessageEntry[] => props.messages?.[type] ??
|
|||||||
const visibleMessages = (type: MessageType): MessageEntry[] => bucket(type).slice(0, visibleLimits[type]);
|
const visibleMessages = (type: MessageType): MessageEntry[] => bucket(type).slice(0, visibleLimits[type]);
|
||||||
|
|
||||||
const permission = computed(() => props.messages?.permission ?? -1);
|
const permission = computed(() => props.messages?.permission ?? -1);
|
||||||
|
const replyableGeneralIds = computed(() =>
|
||||||
|
props.mailboxGroups.flatMap((group) =>
|
||||||
|
group.options
|
||||||
|
.filter((option) => !option.disabled && option.value > 0 && option.value < 9000)
|
||||||
|
.map((option) => option.value)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const setMailbox = (value: string) => {
|
const setMailbox = (value: string) => {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
@@ -239,6 +246,7 @@ const forwardResponse = (messageId: number, response: boolean) => {
|
|||||||
:nation-id="nationId"
|
:nation-id="nationId"
|
||||||
:permission="permission"
|
:permission="permission"
|
||||||
:can-respond-diplomacy="canRespondDiplomacy"
|
:can-respond-diplomacy="canRespondDiplomacy"
|
||||||
|
:replyable-general-ids="replyableGeneralIds"
|
||||||
@set-target="setReplyTarget"
|
@set-target="setReplyTarget"
|
||||||
@delete="emit('delete', $event)"
|
@delete="emit('delete', $event)"
|
||||||
@respond="forwardResponse"
|
@respond="forwardResponse"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const props = defineProps<{
|
|||||||
nationId: number;
|
nationId: number;
|
||||||
permission: number;
|
permission: number;
|
||||||
canRespondDiplomacy: boolean;
|
canRespondDiplomacy: boolean;
|
||||||
|
replyableGeneralIds: number[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -107,6 +108,7 @@ const isBright = (color: string): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const iconUrl = computed(() => resolveMessageGeneralIconUrl(props.message.src.icon));
|
const iconUrl = computed(() => resolveMessageGeneralIconUrl(props.message.src.icon));
|
||||||
|
const canReplyToGeneral = (target: MessageTarget): boolean => props.replyableGeneralIds.includes(target.generalId);
|
||||||
|
|
||||||
const targetClass = (target: MessageTarget) => ({
|
const targetClass = (target: MessageTarget) => ({
|
||||||
'msg-target': true,
|
'msg-target': true,
|
||||||
@@ -167,6 +169,7 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<span class="msg-from-to">▶</span>
|
<span class="msg-from-to">▶</span>
|
||||||
<button
|
<button
|
||||||
|
v-if="canReplyToGeneral(destination)"
|
||||||
:class="targetClass(destination)"
|
:class="targetClass(destination)"
|
||||||
:style="{ backgroundColor: destination.color }"
|
:style="{ backgroundColor: destination.color }"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -174,9 +177,13 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
{{ destination.generalName }}:{{ destination.nationName }} | ↩
|
{{ destination.generalName }}:{{ destination.nationName }} | ↩
|
||||||
</button>
|
</button>
|
||||||
|
<span v-else :class="targetClass(destination)" :style="{ backgroundColor: destination.color }">
|
||||||
|
{{ destination.generalName }}:{{ destination.nationName }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button
|
<button
|
||||||
|
v-if="canReplyToGeneral(message.src)"
|
||||||
:class="targetClass(message.src)"
|
:class="targetClass(message.src)"
|
||||||
:style="{ backgroundColor: message.src.color }"
|
:style="{ backgroundColor: message.src.color }"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -184,6 +191,9 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
{{ message.src.generalName }}:{{ message.src.nationName }} | ↩
|
{{ message.src.generalName }}:{{ message.src.nationName }} | ↩
|
||||||
</button>
|
</button>
|
||||||
|
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
||||||
|
{{ message.src.generalName }}:{{ message.src.nationName }}
|
||||||
|
</span>
|
||||||
<span class="msg-from-to">▶</span>
|
<span class="msg-from-to">▶</span>
|
||||||
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }"
|
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }"
|
||||||
>나</span
|
>나</span
|
||||||
@@ -241,7 +251,7 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-else-if="message.src.generalId !== generalId"
|
v-else-if="message.src.generalId !== generalId && canReplyToGeneral(message.src)"
|
||||||
:class="targetClass(message.src)"
|
:class="targetClass(message.src)"
|
||||||
:style="{ backgroundColor: message.src.color }"
|
:style="{ backgroundColor: message.src.color }"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -249,6 +259,13 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
{{ message.src.generalName }}:{{ message.src.nationName }} | ↩
|
{{ message.src.generalName }}:{{ message.src.nationName }} | ↩
|
||||||
</button>
|
</button>
|
||||||
|
<span
|
||||||
|
v-else-if="message.src.generalId !== generalId"
|
||||||
|
:class="targetClass(message.src)"
|
||||||
|
:style="{ backgroundColor: message.src.color }"
|
||||||
|
>
|
||||||
|
{{ message.src.generalName }}:{{ message.src.nationName }}
|
||||||
|
</span>
|
||||||
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
||||||
{{ message.src.generalName }}
|
{{ message.src.generalName }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -31,15 +31,19 @@ const props = defineProps<{
|
|||||||
class="title"
|
class="title"
|
||||||
:style="{ backgroundColor: props.nation.color, color: legacyNationTextColor(props.nation.color) }"
|
:style="{ backgroundColor: props.nation.color, color: legacyNationTextColor(props.nation.color) }"
|
||||||
>
|
>
|
||||||
{{ props.nation.name }} (Lv {{ props.nation.level }})
|
{{ props.nation.name }}<template v-if="props.nation.id > 0"> (Lv {{ props.nation.level }})</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<span>국고</span><strong>{{ props.nation.gold.toLocaleString() }}</strong> <span>국량</span
|
<span>국고</span
|
||||||
><strong>{{ props.nation.rice.toLocaleString() }}</strong> <span>기술</span
|
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.gold.toLocaleString() }}</strong>
|
||||||
><strong>{{ props.nation.tech.toLocaleString() }}</strong> <span>체제</span
|
<span>국량</span
|
||||||
><strong>{{ props.nation.typeCode }}</strong> <span>수도</span
|
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.rice.toLocaleString() }}</strong>
|
||||||
><strong>{{ props.nation.capitalCityId ?? '-' }}</strong> <span>국가 등급</span
|
<span>기술</span
|
||||||
><strong>{{ props.nation.level }}</strong>
|
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.tech.toLocaleString() }}</strong>
|
||||||
|
<span>체제</span><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.typeCode }}</strong>
|
||||||
|
<span>수도</span
|
||||||
|
><strong>{{ props.nation.id === 0 ? '해당 없음' : (props.nation.capitalCityId ?? '-') }}</strong>
|
||||||
|
<span>국가 등급</span><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.level }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
boardAccess?: BoardAccess | null;
|
boardAccess?: BoardAccess | null;
|
||||||
reservedGeneralTurns?: ReservedTurnView[] | null;
|
reservedGeneralTurns?: ReservedTurnView[] | null;
|
||||||
reservedGeneralRevision?: number;
|
reservedGeneralRevision?: number;
|
||||||
|
reservedGeneralAutorunLimit?: number | null;
|
||||||
globalRecords?: RecentRecord[];
|
globalRecords?: RecentRecord[];
|
||||||
generalRecords?: RecentRecord[];
|
generalRecords?: RecentRecord[];
|
||||||
worldHistory?: RecentRecord[];
|
worldHistory?: RecentRecord[];
|
||||||
@@ -94,6 +95,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const boardAccess = ref<BoardAccess | null>(null);
|
const boardAccess = ref<BoardAccess | null>(null);
|
||||||
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
|
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
|
||||||
const reservedGeneralRevision = ref(0);
|
const reservedGeneralRevision = ref(0);
|
||||||
|
const reservedGeneralAutorunLimit = ref<number | null>(null);
|
||||||
const globalRecords = ref<RecentRecord[]>([]);
|
const globalRecords = ref<RecentRecord[]>([]);
|
||||||
const generalRecords = ref<RecentRecord[]>([]);
|
const generalRecords = ref<RecentRecord[]>([]);
|
||||||
const worldHistory = ref<RecentRecord[]>([]);
|
const worldHistory = ref<RecentRecord[]>([]);
|
||||||
@@ -331,6 +333,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
boardAccess.value = null;
|
boardAccess.value = null;
|
||||||
reservedGeneralTurns.value = null;
|
reservedGeneralTurns.value = null;
|
||||||
reservedGeneralRevision.value = 0;
|
reservedGeneralRevision.value = 0;
|
||||||
|
reservedGeneralAutorunLimit.value = null;
|
||||||
resetRecentRecords(null);
|
resetRecentRecords(null);
|
||||||
commandTableRevision = null;
|
commandTableRevision = null;
|
||||||
boardAccessRevision = null;
|
boardAccessRevision = null;
|
||||||
@@ -351,6 +354,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
boardAccess.value = null;
|
boardAccess.value = null;
|
||||||
reservedGeneralTurns.value = null;
|
reservedGeneralTurns.value = null;
|
||||||
reservedGeneralRevision.value = 0;
|
reservedGeneralRevision.value = 0;
|
||||||
|
reservedGeneralAutorunLimit.value = null;
|
||||||
resetRecentRecords(null);
|
resetRecentRecords(null);
|
||||||
contextRevision = null;
|
contextRevision = null;
|
||||||
commandTableRevision = null;
|
commandTableRevision = null;
|
||||||
@@ -384,6 +388,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
if (patch.reservedGeneralRevision !== undefined) {
|
if (patch.reservedGeneralRevision !== undefined) {
|
||||||
reservedGeneralRevision.value = patch.reservedGeneralRevision;
|
reservedGeneralRevision.value = patch.reservedGeneralRevision;
|
||||||
}
|
}
|
||||||
|
if (patch.reservedGeneralAutorunLimit !== undefined) {
|
||||||
|
reservedGeneralAutorunLimit.value = patch.reservedGeneralAutorunLimit;
|
||||||
|
}
|
||||||
if (patch.globalRecords !== undefined) {
|
if (patch.globalRecords !== undefined) {
|
||||||
globalRecords.value = structurallyShare(globalRecords.value, patch.globalRecords);
|
globalRecords.value = structurallyShare(globalRecords.value, patch.globalRecords);
|
||||||
lastGeneralRecordId = Math.max(lastGeneralRecordId, patch.globalRecords[0]?.id ?? 0);
|
lastGeneralRecordId = Math.max(lastGeneralRecordId, patch.globalRecords[0]?.id ?? 0);
|
||||||
@@ -425,6 +432,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
patch.boardAccess = boardAccessSnapshot ?? null;
|
patch.boardAccess = boardAccessSnapshot ?? null;
|
||||||
patch.reservedGeneralTurns = toRaw(reservedGeneralTurns.value as unknown) as ReservedTurnView[] | null;
|
patch.reservedGeneralTurns = toRaw(reservedGeneralTurns.value as unknown) as ReservedTurnView[] | null;
|
||||||
patch.reservedGeneralRevision = reservedGeneralRevision.value;
|
patch.reservedGeneralRevision = reservedGeneralRevision.value;
|
||||||
|
patch.reservedGeneralAutorunLimit = reservedGeneralAutorunLimit.value;
|
||||||
patch.globalRecords = toRaw(globalRecords.value);
|
patch.globalRecords = toRaw(globalRecords.value);
|
||||||
patch.generalRecords = toRaw(generalRecords.value);
|
patch.generalRecords = toRaw(generalRecords.value);
|
||||||
patch.worldHistory = toRaw(worldHistory.value);
|
patch.worldHistory = toRaw(worldHistory.value);
|
||||||
@@ -552,6 +560,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
generalTurns.turns
|
generalTurns.turns
|
||||||
) as ReservedTurnView[];
|
) as ReservedTurnView[];
|
||||||
reservedGeneralRevision.value = generalTurns.revision;
|
reservedGeneralRevision.value = generalTurns.revision;
|
||||||
|
reservedGeneralAutorunLimit.value = generalTurns.autorunLimit ?? null;
|
||||||
if (records) {
|
if (records) {
|
||||||
applyRecentRecords(records);
|
applyRecentRecords(records);
|
||||||
}
|
}
|
||||||
@@ -672,6 +681,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
if (generalTurns !== undefined) {
|
if (generalTurns !== undefined) {
|
||||||
patch.reservedGeneralTurns = generalTurns.turns;
|
patch.reservedGeneralTurns = generalTurns.turns;
|
||||||
patch.reservedGeneralRevision = generalTurns.revision;
|
patch.reservedGeneralRevision = generalTurns.revision;
|
||||||
|
patch.reservedGeneralAutorunLimit = generalTurns.autorunLimit ?? null;
|
||||||
}
|
}
|
||||||
if (records) {
|
if (records) {
|
||||||
const nextGlobalRecords = mergeRecentRecords(globalRecords.value, records.global);
|
const nextGlobalRecords = mergeRecentRecords(globalRecords.value, records.global);
|
||||||
@@ -836,12 +846,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
});
|
});
|
||||||
reservedGeneralTurns.value = result.turns;
|
reservedGeneralTurns.value = result.turns;
|
||||||
reservedGeneralRevision.value = result.revision;
|
reservedGeneralRevision.value = result.revision;
|
||||||
|
reservedGeneralAutorunLimit.value = result.autorunLimit ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
|
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
reservedGeneralTurns.value = snapshot.turns;
|
reservedGeneralTurns.value = snapshot.turns;
|
||||||
reservedGeneralRevision.value = snapshot.revision;
|
reservedGeneralRevision.value = snapshot.revision;
|
||||||
|
reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -859,12 +871,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
});
|
});
|
||||||
reservedGeneralTurns.value = result.turns;
|
reservedGeneralTurns.value = result.turns;
|
||||||
reservedGeneralRevision.value = result.revision;
|
reservedGeneralRevision.value = result.revision;
|
||||||
|
reservedGeneralAutorunLimit.value = result.autorunLimit ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
|
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
reservedGeneralTurns.value = snapshot.turns;
|
reservedGeneralTurns.value = snapshot.turns;
|
||||||
reservedGeneralRevision.value = snapshot.revision;
|
reservedGeneralRevision.value = snapshot.revision;
|
||||||
|
reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -882,12 +896,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
});
|
});
|
||||||
reservedGeneralTurns.value = result.turns;
|
reservedGeneralTurns.value = result.turns;
|
||||||
reservedGeneralRevision.value = result.revision;
|
reservedGeneralRevision.value = result.revision;
|
||||||
|
reservedGeneralAutorunLimit.value = result.autorunLimit ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
|
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
reservedGeneralTurns.value = snapshot.turns;
|
reservedGeneralTurns.value = snapshot.turns;
|
||||||
reservedGeneralRevision.value = snapshot.revision;
|
reservedGeneralRevision.value = snapshot.revision;
|
||||||
|
reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -903,12 +919,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
});
|
});
|
||||||
reservedGeneralTurns.value = result.turns;
|
reservedGeneralTurns.value = result.turns;
|
||||||
reservedGeneralRevision.value = result.revision;
|
reservedGeneralRevision.value = result.revision;
|
||||||
|
reservedGeneralAutorunLimit.value = result.autorunLimit ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
|
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
reservedGeneralTurns.value = snapshot.turns;
|
reservedGeneralTurns.value = snapshot.turns;
|
||||||
reservedGeneralRevision.value = snapshot.revision;
|
reservedGeneralRevision.value = snapshot.revision;
|
||||||
|
reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1192,6 +1210,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
messageContacts,
|
messageContacts,
|
||||||
boardAccess,
|
boardAccess,
|
||||||
reservedGeneralTurns,
|
reservedGeneralTurns,
|
||||||
|
reservedGeneralAutorunLimit,
|
||||||
globalRecords,
|
globalRecords,
|
||||||
generalRecords,
|
generalRecords,
|
||||||
worldHistory,
|
worldHistory,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<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 MapViewer from '../components/main/MapViewer.vue';
|
import MapViewer from '../components/main/MapViewer.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
@@ -10,6 +10,8 @@ type Layout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
|
|||||||
const data = ref<Result | null>(null);
|
const data = ref<Result | null>(null);
|
||||||
const layout = ref<Layout | null>(null);
|
const layout = ref<Layout | null>(null);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
const matrixElement = ref<HTMLTableElement | null>(null);
|
||||||
|
const matrixHeight = ref<number | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const goBack = () => router.push('/');
|
const goBack = () => router.push('/');
|
||||||
const state = (value: number) => ({ 0: '★', 1: '▲', 2: '', 7: '@' })[value] ?? 'ㆍ';
|
const state = (value: number) => ({ 0: '★', 1: '▲', 2: '', 7: '@' })[value] ?? 'ㆍ';
|
||||||
@@ -19,6 +21,23 @@ const nationNameStyle = (color: string) => ({
|
|||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
color: legacyNationTextColor(color),
|
color: legacyNationTextColor(color),
|
||||||
});
|
});
|
||||||
|
watch(
|
||||||
|
matrixElement,
|
||||||
|
(element, _previousElement, onCleanup) => {
|
||||||
|
if (!element) {
|
||||||
|
matrixHeight.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updateHeight = () => {
|
||||||
|
matrixHeight.value = element.getBoundingClientRect().height;
|
||||||
|
};
|
||||||
|
const observer = new ResizeObserver(updateHeight);
|
||||||
|
observer.observe(element);
|
||||||
|
updateHeight();
|
||||||
|
onCleanup(() => observer.disconnect());
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
);
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
[data.value, layout.value] = await Promise.all([
|
[data.value, layout.value] = await Promise.all([
|
||||||
@@ -39,8 +58,8 @@ onMounted(async () => {
|
|||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<section v-if="data" class="section">
|
<section v-if="data" class="section">
|
||||||
<h2 class="blue">외교 현황</h2>
|
<h2 class="blue">외교 현황</h2>
|
||||||
<div class="matrix-wrap">
|
<div class="matrix-wrap" :style="{ height: matrixHeight === null ? undefined : `${matrixHeight}px` }">
|
||||||
<table class="matrix">
|
<table ref="matrixElement" class="matrix">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -194,7 +213,6 @@ onMounted(async () => {
|
|||||||
background: green;
|
background: green;
|
||||||
}
|
}
|
||||||
.matrix-wrap {
|
.matrix-wrap {
|
||||||
height: 1212.5px;
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const {
|
|||||||
messages,
|
messages,
|
||||||
boardAccess,
|
boardAccess,
|
||||||
reservedGeneralTurns,
|
reservedGeneralTurns,
|
||||||
|
reservedGeneralAutorunLimit,
|
||||||
globalRecords,
|
globalRecords,
|
||||||
generalRecords,
|
generalRecords,
|
||||||
worldHistory,
|
worldHistory,
|
||||||
@@ -218,6 +219,7 @@ watch(
|
|||||||
:current-year="lobbyInfo?.year"
|
:current-year="lobbyInfo?.year"
|
||||||
:current-month="lobbyInfo?.month"
|
:current-month="lobbyInfo?.month"
|
||||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||||
|
:autorun-limit="reservedGeneralAutorunLimit"
|
||||||
@set-general-turns="reserveGeneralTurns"
|
@set-general-turns="reserveGeneralTurns"
|
||||||
@shift-general-turns="shiftGeneralTurns"
|
@shift-general-turns="shiftGeneralTurns"
|
||||||
@repeat-general-turns="repeatGeneralTurns"
|
@repeat-general-turns="repeatGeneralTurns"
|
||||||
@@ -341,6 +343,7 @@ watch(
|
|||||||
:current-year="lobbyInfo?.year"
|
:current-year="lobbyInfo?.year"
|
||||||
:current-month="lobbyInfo?.month"
|
:current-month="lobbyInfo?.month"
|
||||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||||
|
:autorun-limit="reservedGeneralAutorunLimit"
|
||||||
@set-general-turns="reserveGeneralTurns"
|
@set-general-turns="reserveGeneralTurns"
|
||||||
@shift-general-turns="shiftGeneralTurns"
|
@shift-general-turns="shiftGeneralTurns"
|
||||||
@repeat-general-turns="repeatGeneralTurns"
|
@repeat-general-turns="repeatGeneralTurns"
|
||||||
@@ -571,6 +574,7 @@ button {
|
|||||||
.layout-desktop {
|
.layout-desktop {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(10, minmax(0, 1fr));
|
grid-template-columns: repeat(10, minmax(0, 1fr));
|
||||||
|
grid-template-rows: 520px minmax(125px, auto) auto;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -584,32 +588,30 @@ button {
|
|||||||
|
|
||||||
.layout-desktop > [data-main-target='commands'] {
|
.layout-desktop > [data-main-target='commands'] {
|
||||||
grid-column: 8 / 11;
|
grid-column: 8 / 11;
|
||||||
grid-row: 1;
|
grid-row: 1 / 3;
|
||||||
height: 645px;
|
min-height: 645px;
|
||||||
width: 290px;
|
width: 290px;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
overflow-y: auto;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-desktop > [data-main-target='city'] {
|
.layout-desktop > [data-main-target='city'] {
|
||||||
grid-column: 1 / 8;
|
grid-column: 1 / 8;
|
||||||
grid-row: 1;
|
grid-row: 2;
|
||||||
|
align-self: stretch;
|
||||||
min-height: 125px;
|
min-height: 125px;
|
||||||
margin-top: 520px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-desktop > [data-main-target='nation'] {
|
.layout-desktop > [data-main-target='nation'] {
|
||||||
grid-column: 1 / 6;
|
grid-column: 1 / 6;
|
||||||
grid-row: 1;
|
grid-row: 3;
|
||||||
min-height: 193px;
|
min-height: 193px;
|
||||||
margin-top: 645px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-desktop > [data-main-target='general'] {
|
.layout-desktop > [data-main-target='general'] {
|
||||||
grid-column: 6 / 11;
|
grid-column: 6 / 11;
|
||||||
grid-row: 1;
|
grid-row: 3;
|
||||||
min-height: 193px;
|
min-height: 193px;
|
||||||
margin-top: 645px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-desktop > [data-main-target],
|
.layout-desktop > [data-main-target],
|
||||||
@@ -719,8 +721,8 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layout-mobile [data-main-target='commands'] {
|
.layout-mobile [data-main-target='commands'] {
|
||||||
height: 645px;
|
min-height: 645px;
|
||||||
overflow-y: auto;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-mobile [data-main-target='nation'],
|
.layout-mobile [data-main-target='nation'],
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const history = ref<HistoryData | null>(null);
|
|||||||
const selectedYearMonth = ref<number | null>(null);
|
const selectedYearMonth = ref<number | null>(null);
|
||||||
const settingsOpen = ref(false);
|
const settingsOpen = ref(false);
|
||||||
const rankingBottom = ref(localStorage.getItem('yearbook-ranking-bottom') === 'true');
|
const rankingBottom = ref(localStorage.getItem('yearbook-ranking-bottom') === 'true');
|
||||||
|
let historyRequestId = 0;
|
||||||
const serverID = computed(() => {
|
const serverID = computed(() => {
|
||||||
const value = route.query.serverID;
|
const value = route.query.serverID;
|
||||||
const raw = Array.isArray(value) ? value[0] : value;
|
const raw = Array.isArray(value) ? value[0] : value;
|
||||||
@@ -71,19 +72,27 @@ const loadHistory = async (): Promise<void> => {
|
|||||||
if (selectedYearMonth.value === null) {
|
if (selectedYearMonth.value === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const requestId = ++historyRequestId;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
try {
|
try {
|
||||||
const { year, month } = parseYearMonth(selectedYearMonth.value);
|
const { year, month } = parseYearMonth(selectedYearMonth.value);
|
||||||
const result = await trpc.yearbook.getHistory.query({ year, month, serverID: serverID.value });
|
const result = await trpc.yearbook.getHistory.query({ year, month, serverID: serverID.value });
|
||||||
|
if (requestId !== historyRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ('data' in result) {
|
if ('data' in result) {
|
||||||
history.value = result.data;
|
history.value = result.data;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
history.value = null;
|
if (requestId !== historyRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
errorMessage.value = error instanceof Error ? error.message : '연감 데이터를 불러오지 못했습니다.';
|
errorMessage.value = error instanceof Error ? error.message : '연감 데이터를 불러오지 못했습니다.';
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (requestId === historyRequestId) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,7 +138,13 @@ onMounted(async () => {
|
|||||||
<strong>연 감</strong>
|
<strong>연 감</strong>
|
||||||
<button class="legacy-button close-button" type="button" @click="closePage">창 닫기</button>
|
<button class="legacy-button close-button" type="button" @click="closePage">창 닫기</button>
|
||||||
<span class="settings-menu">
|
<span class="settings-menu">
|
||||||
<button class="legacy-button legacy-button--navigation" type="button" @click="settingsOpen = !settingsOpen">⚙ 설정⌄</button>
|
<button
|
||||||
|
class="legacy-button legacy-button--navigation"
|
||||||
|
type="button"
|
||||||
|
@click="settingsOpen = !settingsOpen"
|
||||||
|
>
|
||||||
|
⚙ 설정⌄
|
||||||
|
</button>
|
||||||
<button v-if="settingsOpen" class="settings-item" type="button" @click="toggleRankingPosition">
|
<button v-if="settingsOpen" class="settings-item" type="button" @click="toggleRankingPosition">
|
||||||
국가 순서 위치 변경(모바일 전용)
|
국가 순서 위치 변경(모바일 전용)
|
||||||
</button>
|
</button>
|
||||||
@@ -164,9 +179,9 @@ onMounted(async () => {
|
|||||||
<div v-if="errorMessage" class="yearbook-message error" role="alert">{{ errorMessage }}</div>
|
<div v-if="errorMessage" class="yearbook-message error" role="alert">{{ errorMessage }}</div>
|
||||||
<div v-else-if="loading && !history" class="yearbook-message">불러오는 중...</div>
|
<div v-else-if="loading && !history" class="yearbook-message">불러오는 중...</div>
|
||||||
|
|
||||||
<section v-if="history" :class="['history-grid', { 'ranking-bottom': rankingBottom }]">
|
<section v-if="history" :class="['history-grid', { 'ranking-bottom': rankingBottom }]" :aria-busy="loading">
|
||||||
<div class="map-position">
|
<div class="map-position">
|
||||||
<MapViewer :map-data="history.map" :map-layout="mapLayout" :loading="loading" />
|
<MapViewer :map-data="history.map" :map-layout="mapLayout" :loading="loading && !history" />
|
||||||
</div>
|
</div>
|
||||||
<aside class="nation-position">
|
<aside class="nation-position">
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -28,7 +28,16 @@ const fulfillTrpc = async (route: Route, results: unknown[]): Promise<void> => {
|
|||||||
|
|
||||||
type LobbyFixtureOptions = {
|
type LobbyFixtureOptions = {
|
||||||
authenticated?: boolean;
|
authenticated?: boolean;
|
||||||
|
roles?: string[];
|
||||||
|
kakaoVerified?: boolean;
|
||||||
canCreateGeneral?: boolean;
|
canCreateGeneral?: boolean;
|
||||||
|
requiresKakaoVerification?: boolean;
|
||||||
|
specialAccess?: {
|
||||||
|
kind: 'OPERATOR' | 'TESTER' | 'RECOVERY' | 'OTHER';
|
||||||
|
grantId: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
allowsGeneralCreation: boolean;
|
||||||
|
} | null;
|
||||||
myGeneral?: {
|
myGeneral?: {
|
||||||
name: string;
|
name: string;
|
||||||
picture: string;
|
picture: string;
|
||||||
@@ -48,7 +57,11 @@ type LobbyFixtureOptions = {
|
|||||||
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
|
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
|
||||||
const {
|
const {
|
||||||
authenticated = true,
|
authenticated = true,
|
||||||
|
roles = ['user'],
|
||||||
|
kakaoVerified = true,
|
||||||
canCreateGeneral = true,
|
canCreateGeneral = true,
|
||||||
|
requiresKakaoVerification = false,
|
||||||
|
specialAccess = null,
|
||||||
myGeneral = {
|
myGeneral = {
|
||||||
name: '선택장수',
|
name: '선택장수',
|
||||||
picture: 'users/core2026/account-hash.png',
|
picture: 'users/core2026/account-hash.png',
|
||||||
@@ -79,8 +92,8 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
|||||||
id: 'lobby-user',
|
id: 'lobby-user',
|
||||||
username: 'lobby-user',
|
username: 'lobby-user',
|
||||||
displayName: '로비사용자',
|
displayName: '로비사용자',
|
||||||
roles: ['user'],
|
roles,
|
||||||
kakaoVerified: true,
|
kakaoVerified,
|
||||||
createdAt: '2026-07-30T00:00:00.000Z',
|
createdAt: '2026-07-30T00:00:00.000Z',
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
@@ -109,8 +122,9 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
|||||||
localAccountPolicy: {
|
localAccountPolicy: {
|
||||||
accessAllowed: true,
|
accessAllowed: true,
|
||||||
canCreateGeneral,
|
canCreateGeneral,
|
||||||
requiresKakaoVerification: false,
|
requiresKakaoVerification,
|
||||||
graceEndsAt: null,
|
graceEndsAt: null,
|
||||||
|
specialAccess,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -216,6 +230,51 @@ test('applies the signed general-acquisition policy to both create and possessio
|
|||||||
await expect(row.getByRole('button', { name: '장수빙의' })).toBeDisabled();
|
await expect(row.getByRole('button', { name: '장수빙의' })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows the Kakao verification banner when a profile still requires verification', async ({ page }) => {
|
||||||
|
await installFixture(page, {
|
||||||
|
kakaoVerified: false,
|
||||||
|
requiresKakaoVerification: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('lobby');
|
||||||
|
await expect(page.getByText('카카오 인증이 필요합니다.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides the Kakao verification banner for operator special access', async ({ page }) => {
|
||||||
|
await installFixture(page, {
|
||||||
|
roles: ['superuser'],
|
||||||
|
kakaoVerified: false,
|
||||||
|
specialAccess: {
|
||||||
|
kind: 'OPERATOR',
|
||||||
|
grantId: null,
|
||||||
|
expiresAt: null,
|
||||||
|
allowsGeneralCreation: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('lobby');
|
||||||
|
await expect(page.getByText('특수 접근 · OPERATOR')).toBeVisible();
|
||||||
|
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides the Kakao verification banner when a grant removes the remaining verification requirement', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installFixture(page, {
|
||||||
|
kakaoVerified: false,
|
||||||
|
specialAccess: {
|
||||||
|
kind: 'RECOVERY',
|
||||||
|
grantId: '11111111-1111-4111-8111-111111111111',
|
||||||
|
expiresAt: '2026-08-20T00:00:00.000Z',
|
||||||
|
allowsGeneralCreation: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('lobby');
|
||||||
|
await expect(page.getByText('특수 접근 · RECOVERY')).toBeVisible();
|
||||||
|
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('opens the profile root without profile or game token query parameters', async ({ page }) => {
|
test('opens the profile root without profile or game token query parameters', async ({ page }) => {
|
||||||
await installFixture(page);
|
await installFixture(page);
|
||||||
await page.route('**/hwe/', async (route) => {
|
await page.route('**/hwe/', async (route) => {
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ const canAccessAdmin = computed(
|
|||||||
role === 'superuser' || role === 'admin' || role === 'admin.superuser' || role.startsWith('admin.')
|
role === 'superuser' || role === 'admin' || role === 'admin.superuser' || role.startsWith('admin.')
|
||||||
) ?? false
|
) ?? false
|
||||||
);
|
);
|
||||||
const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified);
|
const needsKakaoVerification = computed(
|
||||||
|
() =>
|
||||||
|
me.value !== null &&
|
||||||
|
!me.value.kakaoVerified &&
|
||||||
|
profiles.value.some((profile) => profile.localAccountPolicy?.requiresKakaoVerification === true)
|
||||||
|
);
|
||||||
const userIconBaseUrl = configuredUserIconPublicUrl();
|
const userIconBaseUrl = configuredUserIconPublicUrl();
|
||||||
const sharedIconBaseUrl = configuredSharedIconPublicUrl();
|
const sharedIconBaseUrl = configuredSharedIconPublicUrl();
|
||||||
const publicMapProfiles = computed(() =>
|
const publicMapProfiles = computed(() =>
|
||||||
|
|||||||
@@ -232,8 +232,24 @@ export const canonicalFrontendFixture = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
nations: [
|
nations: [
|
||||||
{ id: 1, name: '한', color: '#d32f2f', level: 5, power: 1250, cities: ['낙양', '업'] },
|
{
|
||||||
{ id: 2, name: '진', color: '#1976d2', level: 4, power: 980, cities: ['장안'] },
|
id: 1,
|
||||||
|
name: '한',
|
||||||
|
color: '#d32f2f',
|
||||||
|
level: 5,
|
||||||
|
power: 1250,
|
||||||
|
generalCount: 12,
|
||||||
|
cities: ['낙양', '업'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '진',
|
||||||
|
color: '#1976d2',
|
||||||
|
level: 4,
|
||||||
|
power: 980,
|
||||||
|
generalCount: 8,
|
||||||
|
cities: ['장안'],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
globalHistory: ['<C>●</> 한이 낙양을 지키고 있습니다.'],
|
globalHistory: ['<C>●</> 한이 낙양을 지키고 있습니다.'],
|
||||||
globalAction: ['<L>●</> 유비가 내정을 수행했습니다.'],
|
globalAction: ['<L>●</> 유비가 내정을 수행했습니다.'],
|
||||||
|
|||||||
@@ -971,6 +971,108 @@ test.describe('yearbook legacy parity', () => {
|
|||||||
await expect(page.getByRole('alert')).toBeVisible();
|
await expect(page.getByRole('alert')).toBeVisible();
|
||||||
await expect(page.getByLabel('연월 선택')).toBeVisible();
|
await expect(page.getByLabel('연월 선택')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps the rendered yearbook in place while moving between months', async ({ page }) => {
|
||||||
|
await page.goto(gameUrl('/yearbook'));
|
||||||
|
await expect(page.getByText('한이 낙양을 지키고 있습니다.')).toBeVisible();
|
||||||
|
|
||||||
|
let releaseHistory: (() => void) | undefined;
|
||||||
|
const historyReleased = new Promise<void>((resolve) => {
|
||||||
|
releaseHistory = resolve;
|
||||||
|
});
|
||||||
|
let markHistoryRequested: (() => void) | undefined;
|
||||||
|
const historyRequested = new Promise<void>((resolve) => {
|
||||||
|
markHistoryRequested = resolve;
|
||||||
|
});
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
if (!operationNames(route).includes('yearbook.getHistory')) {
|
||||||
|
await route.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markHistoryRequested?.();
|
||||||
|
await historyReleased;
|
||||||
|
await fulfillOperations(route, () => ({
|
||||||
|
notModified: false,
|
||||||
|
hash: 'yearbook-previous-month-hash',
|
||||||
|
data: {
|
||||||
|
...fixture.game.yearbook.data,
|
||||||
|
year: 197,
|
||||||
|
month: 6,
|
||||||
|
map: {
|
||||||
|
...fixture.game.yearbook.data.map,
|
||||||
|
year: 197,
|
||||||
|
month: 6,
|
||||||
|
},
|
||||||
|
nations: fixture.game.yearbook.data.nations.map((nation, index) => ({
|
||||||
|
...nation,
|
||||||
|
generalCount: index === 0 ? 12 : 8,
|
||||||
|
})),
|
||||||
|
globalHistory: ['<C>●</> 이전 달의 중원 정세입니다.'],
|
||||||
|
globalAction: ['<L>●</> 이전 달의 장수 동향입니다.'],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const grid = document.querySelector<HTMLElement>('.history-grid')!;
|
||||||
|
const transition = {
|
||||||
|
grid,
|
||||||
|
mapBody: document.querySelector<HTMLElement>('.map-body')!,
|
||||||
|
nationBody: document.querySelector<HTMLElement>('.nation-position tbody')!,
|
||||||
|
removedNodes: 0,
|
||||||
|
};
|
||||||
|
new MutationObserver((records) => {
|
||||||
|
transition.removedNodes += records.reduce((count, record) => count + record.removedNodes.length, 0);
|
||||||
|
}).observe(grid, { childList: true, subtree: true });
|
||||||
|
(window as unknown as { __yearbookTransition: typeof transition }).__yearbookTransition = transition;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '◀ 이전달' }).click();
|
||||||
|
await historyRequested;
|
||||||
|
|
||||||
|
await expect(page.locator('.history-grid')).toHaveAttribute('aria-busy', 'true');
|
||||||
|
await expect(page.locator('.map-body')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.map-viewer .skeleton-lines')).toHaveCount(0);
|
||||||
|
await expect(page.getByText('한이 낙양을 지키고 있습니다.')).toBeVisible();
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const transition = (
|
||||||
|
window as unknown as {
|
||||||
|
__yearbookTransition: {
|
||||||
|
grid: Element;
|
||||||
|
mapBody: Element;
|
||||||
|
nationBody: Element;
|
||||||
|
removedNodes: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).__yearbookTransition;
|
||||||
|
return {
|
||||||
|
gridIsSame: document.querySelector('.history-grid') === transition.grid,
|
||||||
|
mapIsSame: document.querySelector('.map-body') === transition.mapBody,
|
||||||
|
nationIsSame: document.querySelector('.nation-position tbody') === transition.nationBody,
|
||||||
|
removedNodes: transition.removedNodes,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
).toEqual({ gridIsSame: true, mapIsSame: true, nationIsSame: true, removedNodes: 0 });
|
||||||
|
|
||||||
|
releaseHistory?.();
|
||||||
|
await expect(page.getByText('이전 달의 중원 정세입니다.')).toBeVisible();
|
||||||
|
await expect(page.locator('.history-grid')).toHaveAttribute('aria-busy', 'false');
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const transition = (
|
||||||
|
window as unknown as {
|
||||||
|
__yearbookTransition: { grid: Element; mapBody: Element; nationBody: Element };
|
||||||
|
}
|
||||||
|
).__yearbookTransition;
|
||||||
|
return {
|
||||||
|
gridIsSame: document.querySelector('.history-grid') === transition.grid,
|
||||||
|
mapIsSame: document.querySelector('.map-body') === transition.mapBody,
|
||||||
|
nationIsSame: document.querySelector('.nation-position tbody') === transition.nationBody,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
).toEqual({ gridIsSame: true, mapIsSame: true, nationIsSame: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('survey legacy parity', () => {
|
test.describe('survey legacy parity', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user