merge: 최신 main을 유저 preset 아이콘 차단에 통합한다
This commit is contained in:
@@ -56,6 +56,7 @@ const zGeneralSettings = z.object({
|
||||
use_treatment: z.number().int().optional(),
|
||||
use_auto_nation_turn: z.number().int().optional(),
|
||||
use_auto_nation_diplomacy: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_war: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_promotion: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_finance: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_capital: z.number().int().min(0).max(1).optional(),
|
||||
@@ -218,6 +219,7 @@ const resolveUserSettings = (meta: Record<string, unknown>) => {
|
||||
// Ref가 NPC 군주에게만 수행하던 국가 운영은 사용자 군주에게 opt-in이다.
|
||||
// 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다.
|
||||
use_auto_nation_diplomacy: readNumber(readSetting('use_auto_nation_diplomacy'), 0),
|
||||
use_auto_nation_war: readNumber(readSetting('use_auto_nation_war'), 0),
|
||||
use_auto_nation_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0),
|
||||
use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0),
|
||||
use_auto_nation_capital: readNumber(readSetting('use_auto_nation_capital'), 0),
|
||||
|
||||
@@ -287,12 +287,16 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
accountIconUpdatedAt: '2026-07-30T00:00:00.000Z',
|
||||
});
|
||||
const createdAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: created.id } });
|
||||
const acceptedEvent = await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `join-create:${userId}:${clientRequestId}` },
|
||||
});
|
||||
if (!createdAccess.lastRefresh) {
|
||||
throw new Error('created general must have an initial access timestamp');
|
||||
}
|
||||
expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
|
||||
expect(
|
||||
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
|
||||
createdAccess.lastRefresh.getTime()
|
||||
acceptedEvent.createdAt.getTime()
|
||||
).toBe(2 * 5 * 60 * 1_000);
|
||||
expect(runtime!.world.getGeneralById(created.id)).toMatchObject({
|
||||
id: created.id,
|
||||
@@ -354,7 +358,7 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
attempts: 1,
|
||||
actorUserId: userId,
|
||||
});
|
||||
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime());
|
||||
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
|
||||
const turnGridOffsetSeconds =
|
||||
((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300;
|
||||
expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35);
|
||||
|
||||
@@ -585,6 +585,7 @@ describe('in-game my information ownership', () => {
|
||||
use_treatment: 21,
|
||||
use_auto_nation_turn: 1,
|
||||
use_auto_nation_diplomacy: 0,
|
||||
use_auto_nation_war: 0,
|
||||
use_auto_nation_promotion: 0,
|
||||
use_auto_nation_finance: 0,
|
||||
use_auto_nation_capital: 0,
|
||||
@@ -600,6 +601,16 @@ describe('in-game my information ownership', () => {
|
||||
expect(fixture.db.general.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid automatic war setting before dispatching it to ENGINE', async () => {
|
||||
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
|
||||
const fixture = createContext({ requestCommand });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).general.setMySetting({ use_auto_nation_war: 2 })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends settings directly to ENGINE without creating an API input event', async () => {
|
||||
const transaction = vi.fn(async () => {
|
||||
throw new Error('API transaction must not run');
|
||||
|
||||
@@ -258,12 +258,17 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
const initialRuntime = runtime!.world.getGeneralById(initial.id);
|
||||
const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } });
|
||||
expect(initial).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
|
||||
const acceptedEvent = await db.inputEvent.findFirstOrThrow({
|
||||
where: { actorUserId: userId, eventType: 'selectPoolCreate', status: 'SUCCEEDED' },
|
||||
orderBy: { sequence: 'desc' },
|
||||
});
|
||||
if (!initialAccess.lastRefresh) {
|
||||
throw new Error('selected general must have an initial access timestamp');
|
||||
}
|
||||
expect(initialAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
|
||||
expect(
|
||||
new Date((initial.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
|
||||
initialAccess.lastRefresh.getTime()
|
||||
acceptedEvent.createdAt.getTime()
|
||||
).toBe(2 * 5 * 60 * 1_000);
|
||||
expect(initialRuntime).toMatchObject({
|
||||
id: initial.id,
|
||||
|
||||
@@ -61,10 +61,11 @@ export const AVAILABLE_INSTANT_TURN: Record<string, boolean> = {
|
||||
NPC전방발령: true,
|
||||
};
|
||||
|
||||
export type UserRulerAutomationFeature = 'diplomacy' | 'promotion' | 'finance' | 'capital';
|
||||
export type UserRulerAutomationFeature = 'diplomacy' | 'war' | 'promotion' | 'finance' | 'capital';
|
||||
|
||||
const USER_RULER_AUTOMATION_META_KEY = {
|
||||
diplomacy: 'use_auto_nation_diplomacy',
|
||||
war: 'use_auto_nation_war',
|
||||
promotion: 'use_auto_nation_promotion',
|
||||
finance: 'use_auto_nation_finance',
|
||||
capital: 'use_auto_nation_capital',
|
||||
@@ -72,7 +73,7 @@ const USER_RULER_AUTOMATION_META_KEY = {
|
||||
|
||||
const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeature>> = {
|
||||
불가침제의: 'diplomacy',
|
||||
선전포고: 'diplomacy',
|
||||
선전포고: 'war',
|
||||
천도: 'capital',
|
||||
};
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ const zSetMySetting = z.object({
|
||||
use_treatment: z.number().int().optional(),
|
||||
use_auto_nation_turn: z.number().int().optional(),
|
||||
use_auto_nation_diplomacy: z.number().int().optional(),
|
||||
use_auto_nation_war: z.number().int().optional(),
|
||||
use_auto_nation_promotion: z.number().int().optional(),
|
||||
use_auto_nation_finance: z.number().int().optional(),
|
||||
use_auto_nation_capital: z.number().int().optional(),
|
||||
|
||||
@@ -522,8 +522,9 @@ export const createGeneralFromJoin = async (options: {
|
||||
worldState: WorldStateRow;
|
||||
input: JoinCreateGeneralInput;
|
||||
acceptedAt: Date;
|
||||
operationalAcceptedAt: Date;
|
||||
}): Promise<{ ok: true; generalId: number }> => {
|
||||
const { db, world, worldState, input, acceptedAt } = options;
|
||||
const { db, world, worldState, input, acceptedAt, operationalAcceptedAt } = options;
|
||||
await lockJoinMutation(db, input.userId);
|
||||
await assertGeneralIdSnapshotMatches(db, world);
|
||||
|
||||
@@ -734,7 +735,10 @@ export const createGeneralFromJoin = async (options: {
|
||||
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
|
||||
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId);
|
||||
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
|
||||
const prestartDeleteAfter = buildPrestartDeleteAfter(acceptedAt, worldState.tickSeconds, config);
|
||||
// Ref의 가오픈 삭제 대기는 정지된 게임 clock이 아니라 실제 요청 접수 시각부터 흐른다.
|
||||
// 미래 정식 오픈에 clock을 고정한 PREOPEN에서도 사용자가 가오픈 중 두 턴을 기다리면
|
||||
// 삭제할 수 있어야 하므로 RNG/턴 배치용 acceptedAt과 이 벽시계 경계를 분리한다.
|
||||
const prestartDeleteAfter = buildPrestartDeleteAfter(operationalAcceptedAt, worldState.tickSeconds, config);
|
||||
const general: TurnGeneral = {
|
||||
id: generalId,
|
||||
userId: input.userId,
|
||||
@@ -839,11 +843,11 @@ export const createGeneralFromJoin = async (options: {
|
||||
});
|
||||
await db.generalAccessLog.upsert({
|
||||
where: { generalId },
|
||||
update: { userId: input.userId, lastRefresh: acceptedAt },
|
||||
update: { userId: input.userId, lastRefresh: operationalAcceptedAt },
|
||||
create: {
|
||||
generalId,
|
||||
userId: input.userId,
|
||||
lastRefresh: acceptedAt,
|
||||
lastRefresh: operationalAcceptedAt,
|
||||
},
|
||||
});
|
||||
if (inheritRequiredPoint > 0) {
|
||||
|
||||
@@ -702,6 +702,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
uniqueName: string;
|
||||
personality: string;
|
||||
now?: Date;
|
||||
operationalAcceptedAt: Date;
|
||||
seedOwnerIdentity?: string | number;
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
@@ -766,7 +767,7 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
const nextChangeAt = new Date(
|
||||
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
||||
);
|
||||
const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config);
|
||||
const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config);
|
||||
// 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는
|
||||
// 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다.
|
||||
const { picture, imageServer } = resolveSelectionPoolUserIcon({
|
||||
@@ -908,8 +909,8 @@ export const createGeneralFromSelectionPool = async (options: {
|
||||
}
|
||||
await db.generalAccessLog.upsert({
|
||||
where: { generalId },
|
||||
update: { userId, lastRefresh: now },
|
||||
create: { generalId, userId, lastRefresh: now },
|
||||
update: { userId, lastRefresh: options.operationalAcceptedAt },
|
||||
create: { generalId, userId, lastRefresh: options.operationalAcceptedAt },
|
||||
});
|
||||
await clearUnusedReservations(db, userId, now, nowTick);
|
||||
await synchronizeSelectionPoolWorld(db, world);
|
||||
|
||||
@@ -296,6 +296,7 @@ async function handleJoinCreateGeneral(
|
||||
...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}),
|
||||
},
|
||||
acceptedAt,
|
||||
operationalAcceptedAt,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -366,7 +367,13 @@ async function handleSelectPoolCreate(
|
||||
if (!worldState) {
|
||||
throw new Error('Selection-pool world state is missing.');
|
||||
}
|
||||
const acceptedAt = await resolveSelectionCommandAcceptedAt(db, ctx.world, command);
|
||||
const operationalAcceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
const acceptedAt =
|
||||
command.acceptedGameTick !== undefined
|
||||
? ctx.world.gameTickToDate(command.acceptedGameTick)
|
||||
: command.acceptedGameAt !== undefined
|
||||
? new Date(command.acceptedGameAt)
|
||||
: ctx.world.getGameNow(operationalAcceptedAt);
|
||||
try {
|
||||
return {
|
||||
type: 'selectPoolCreate',
|
||||
@@ -383,6 +390,7 @@ async function handleSelectPoolCreate(
|
||||
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
|
||||
...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}),
|
||||
now: acceptedAt,
|
||||
operationalAcceptedAt,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -1779,6 +1787,7 @@ async function handleSetMySetting(
|
||||
}
|
||||
for (const key of [
|
||||
'use_auto_nation_diplomacy',
|
||||
'use_auto_nation_war',
|
||||
'use_auto_nation_promotion',
|
||||
'use_auto_nation_finance',
|
||||
'use_auto_nation_capital',
|
||||
@@ -1988,7 +1997,7 @@ async function handleKick(
|
||||
}
|
||||
|
||||
const target = world.getGeneralById(command.destGeneralId);
|
||||
if (!target || target.id === general.id || target.nationId !== general.nationId) {
|
||||
if (!target || target.nationId !== general.nationId) {
|
||||
return {
|
||||
type: 'kick',
|
||||
ok: false,
|
||||
@@ -1996,7 +2005,18 @@ async function handleKick(
|
||||
reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.',
|
||||
};
|
||||
}
|
||||
if (resolveMaxSecretPermission(target) === 4 && resolvePermissionKind(target) === 'ambassador') {
|
||||
if (target.id === general.id) {
|
||||
return { type: 'kick', ok: false, generalId: command.generalId, reason: '본인은 추방할 수 없습니다.' };
|
||||
}
|
||||
// Ref 화면은 군주와 본인을 후보에서 제외하지만 서버는 조작 요청을 막지 못했다.
|
||||
// 국가 소유권을 깨뜨리는 대상은 UI와 무관하게 durable command 경계에서 거부한다.
|
||||
if (target.id === nation.chiefGeneralId || target.officerLevel === 12) {
|
||||
return { type: 'kick', ok: false, generalId: command.generalId, reason: '군주는 추방할 수 없습니다.' };
|
||||
}
|
||||
if (target.officerLevel >= 5) {
|
||||
return { type: 'kick', ok: false, generalId: command.generalId, reason: '수뇌는 추방할 수 없습니다.' };
|
||||
}
|
||||
if (resolvePermissionKind(target) === 'ambassador') {
|
||||
return {
|
||||
type: 'kick',
|
||||
ok: false,
|
||||
|
||||
@@ -718,6 +718,7 @@ describe('legacy NPC user-chief promotion parity', () => {
|
||||
it('keeps user-ruler duties individually disabled until each setting is enabled', () => {
|
||||
const ruler = makePromotionGeneral({ id: 1, officerLevel: 12, npcState: 0, meta: { killturn: 0 } });
|
||||
expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(false);
|
||||
expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(false);
|
||||
expect(canUseAutomatedNationAction(ruler, '천도')).toBe(false);
|
||||
expect(canUseRulerAutomation(ruler, 'finance')).toBe(false);
|
||||
|
||||
@@ -728,9 +729,17 @@ describe('legacy NPC user-chief promotion parity', () => {
|
||||
use_auto_nation_finance: 1,
|
||||
};
|
||||
expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(true);
|
||||
expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(true);
|
||||
expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(false);
|
||||
expect(canUseAutomatedNationAction(ruler, '천도')).toBe(true);
|
||||
expect(canUseRulerAutomation(ruler, 'finance')).toBe(true);
|
||||
|
||||
ruler.meta = {
|
||||
...ruler.meta,
|
||||
use_auto_nation_diplomacy: 0,
|
||||
use_auto_nation_war: 1,
|
||||
};
|
||||
expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(false);
|
||||
expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(true);
|
||||
});
|
||||
|
||||
it('honors the existing automatic nation-turn master switch for user chiefs only', () => {
|
||||
|
||||
@@ -223,6 +223,7 @@ describe('my information world commands', () => {
|
||||
use_treatment: 200,
|
||||
use_auto_nation_turn: 0,
|
||||
use_auto_nation_diplomacy: 1,
|
||||
use_auto_nation_war: 1,
|
||||
use_auto_nation_promotion: 1,
|
||||
use_auto_nation_finance: 1,
|
||||
use_auto_nation_capital: 1,
|
||||
@@ -239,6 +240,7 @@ describe('my information world commands', () => {
|
||||
use_treatment: 100,
|
||||
use_auto_nation_turn: 0,
|
||||
use_auto_nation_diplomacy: 1,
|
||||
use_auto_nation_war: 1,
|
||||
use_auto_nation_promotion: 1,
|
||||
use_auto_nation_finance: 1,
|
||||
use_auto_nation_capital: 1,
|
||||
|
||||
@@ -312,6 +312,39 @@ describe('nation personnel world commands', () => {
|
||||
expect(fixture.world.peekDirtyState().logs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rejects self, ruler, head officer, and ambassador targets without partial mutation', async () => {
|
||||
const cases = [
|
||||
{ label: 'self', targetId: 2, reason: '본인은 추방할 수 없습니다.' },
|
||||
{ label: 'ruler', targetId: 1, reason: '군주는 추방할 수 없습니다.' },
|
||||
{ label: 'head officer', targetId: 3, reason: '수뇌는 추방할 수 없습니다.' },
|
||||
{ label: 'ambassador', targetId: 4, reason: '외교권자는 추방할 수 없습니다.' },
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const fixture = buildWorld({
|
||||
generals: [
|
||||
buildGeneral(1, { officerLevel: 12 }),
|
||||
buildGeneral(2, { officerLevel: 5 }),
|
||||
buildGeneral(3, { officerLevel: 7 }),
|
||||
buildGeneral(4, {
|
||||
meta: { killturn: 12, belong: 5, permission: 'ambassador' },
|
||||
penalty: { noAmbassador: true },
|
||||
}),
|
||||
buildGeneral(5),
|
||||
],
|
||||
});
|
||||
const originalTarget = fixture.world.getGeneralById(testCase.targetId);
|
||||
|
||||
await expect(
|
||||
fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: testCase.targetId })
|
||||
).resolves.toMatchObject({ ok: false, reason: testCase.reason });
|
||||
expect(fixture.world.getGeneralById(testCase.targetId), testCase.label).toEqual(originalTarget);
|
||||
expect(fixture.world.getGeneralById(2)?.meta.killturn, testCase.label).toBe(12);
|
||||
expect(fixture.world.peekDirtyState().logs, testCase.label).toEqual([]);
|
||||
expect(fixture.world.peekDirtyState().nations, testCase.label).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the legacy kick year boundaries and deterministic NPC public message', async () => {
|
||||
const early = buildWorld({
|
||||
currentYear: 181,
|
||||
|
||||
@@ -61,10 +61,10 @@ const castleFixtures = [
|
||||
{ id: 2, level: 1, layoutLevel: 8, x: 200, y: 100, width: 16, height: 15 },
|
||||
{ id: 3, level: 2, layoutLevel: 8, x: 300, y: 100, width: 20, height: 14 },
|
||||
{ id: 4, level: 3, layoutLevel: 8, x: 400, y: 100, width: 14, height: 14 },
|
||||
{ id: 5, level: 4, layoutLevel: 8, x: 100, y: 220, width: 20, height: 15 },
|
||||
{ id: 6, level: 5, layoutLevel: 8, x: 200, y: 220, width: 24, height: 16 },
|
||||
{ id: 7, level: 6, layoutLevel: 8, x: 300, y: 220, width: 26, height: 18 },
|
||||
{ id: 8, level: 7, layoutLevel: 8, x: 400, y: 220, width: 28, height: 20 },
|
||||
{ id: 5, name: '남만', level: 4, layoutLevel: 8, x: 80, y: 455, width: 20, height: 15 },
|
||||
{ id: 6, name: '교지', level: 5, layoutLevel: 8, x: 130, y: 480, width: 24, height: 16 },
|
||||
{ id: 7, name: '남해', level: 6, layoutLevel: 8, x: 245, y: 480, width: 26, height: 18 },
|
||||
{ id: 8, name: '대', level: 7, layoutLevel: 8, x: 450, y: 480, width: 28, height: 20 },
|
||||
] as const;
|
||||
const map = {
|
||||
result: true,
|
||||
@@ -82,9 +82,9 @@ const map = {
|
||||
};
|
||||
const layout = {
|
||||
mapName: 'che',
|
||||
cityList: castleFixtures.map(({ id, layoutLevel: level, x, y }) => ({
|
||||
cityList: castleFixtures.map(({ id, layoutLevel: level, x, y, ...fixture }) => ({
|
||||
id,
|
||||
name: id === 1 ? '업' : `성${id}`,
|
||||
name: id === 1 ? '업' : 'name' in fixture ? fixture.name : `성${id}`,
|
||||
level,
|
||||
region: 1,
|
||||
x,
|
||||
@@ -550,6 +550,40 @@ test('map keeps desktop hover navigation and lets touch users choose one-tap or
|
||||
await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0);
|
||||
await desktopCity.hover();
|
||||
await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업');
|
||||
|
||||
for (const cityName of ['남만', '교지', '남해', '대']) {
|
||||
await page.getByRole('link', { name: cityName, exact: true }).hover();
|
||||
await expect(page.locator('.map-tooltip')).toBeVisible();
|
||||
const geometry = await page.locator('.map-area').evaluate((mapArea, expectedCityName) => {
|
||||
const cityElement = Array.from(mapArea.querySelectorAll<HTMLElement>('.city-base')).find(
|
||||
(element) => element.getAttribute('aria-label') === expectedCityName
|
||||
);
|
||||
const tooltip = mapArea.querySelector<HTMLElement>('.map-tooltip');
|
||||
if (!cityElement || !tooltip) throw new Error(`Missing bottom-city hover geometry for ${expectedCityName}`);
|
||||
const mapRect = mapArea.getBoundingClientRect();
|
||||
const cityRect = cityElement.getBoundingClientRect();
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
const controls = mapArea.querySelector<HTMLElement>('.map-controls');
|
||||
const tooltipStyle = getComputedStyle(tooltip);
|
||||
return {
|
||||
map: { top: mapRect.top, bottom: mapRect.bottom },
|
||||
city: { top: cityRect.top, bottom: cityRect.bottom },
|
||||
tooltip: { top: tooltipRect.top, bottom: tooltipRect.bottom, height: tooltipRect.height },
|
||||
tooltipZIndex: Number(tooltipStyle.zIndex),
|
||||
controlsZIndex: controls ? Number(getComputedStyle(controls).zIndex) : null,
|
||||
pointerEvents: tooltipStyle.pointerEvents,
|
||||
};
|
||||
}, cityName);
|
||||
expect(geometry.tooltip.top).toBeGreaterThanOrEqual(geometry.map.top);
|
||||
expect(geometry.tooltip.bottom).toBeLessThanOrEqual(geometry.map.bottom);
|
||||
expect(geometry.tooltip.bottom).toBeLessThan(geometry.city.top);
|
||||
expect(geometry.tooltip.height).toBeGreaterThanOrEqual(32);
|
||||
expect(geometry.tooltipZIndex).toBeGreaterThan(geometry.controlsZIndex ?? 0);
|
||||
expect(geometry.pointerEvents).toBe('none');
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath('desktop-map-bottom-tooltip.png'), fullPage: true });
|
||||
|
||||
await desktopCity.hover();
|
||||
await desktopCity.click();
|
||||
await expect(page).toHaveURL(/\/current-city\?cityId=1$/u);
|
||||
await page.goBack();
|
||||
|
||||
@@ -276,6 +276,7 @@ const myGeneral = (state: FixtureState) => ({
|
||||
use_treatment: 21,
|
||||
use_auto_nation_turn: 1,
|
||||
use_auto_nation_diplomacy: 0,
|
||||
use_auto_nation_war: 0,
|
||||
use_auto_nation_promotion: 0,
|
||||
use_auto_nation_finance: 0,
|
||||
use_auto_nation_capital: 0,
|
||||
@@ -1342,11 +1343,18 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
||||
]);
|
||||
const rulerAutomation = page.locator('.ruler-automation-settings');
|
||||
await expect(rulerAutomation).toBeVisible();
|
||||
const diplomacyAutomation = page.getByRole('checkbox', { name: '자동 외교 (불가침 제의·선전포고)' });
|
||||
const diplomacyAutomation = page.getByRole('checkbox', { name: '자동 외교 (불가침 제의)' });
|
||||
const warAutomation = page.getByRole('checkbox', { name: '자동 선전포고' });
|
||||
const promotionAutomation = page.getByRole('checkbox', { name: '자동 수뇌 임명' });
|
||||
const financeAutomation = page.getByRole('checkbox', { name: '자동 세율·지급률 조정' });
|
||||
const capitalAutomation = page.getByRole('checkbox', { name: '자동 천도' });
|
||||
for (const checkbox of [diplomacyAutomation, promotionAutomation, financeAutomation, capitalAutomation]) {
|
||||
for (const checkbox of [
|
||||
diplomacyAutomation,
|
||||
warAutomation,
|
||||
promotionAutomation,
|
||||
financeAutomation,
|
||||
capitalAutomation,
|
||||
]) {
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
await checkbox.check();
|
||||
}
|
||||
@@ -1410,6 +1418,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
||||
expect(state.settingMutations[0]).not.toHaveProperty('generalId');
|
||||
expect(state.settingMutations[0]).toMatchObject({
|
||||
use_auto_nation_diplomacy: 1,
|
||||
use_auto_nation_war: 1,
|
||||
use_auto_nation_promotion: 1,
|
||||
use_auto_nation_finance: 1,
|
||||
use_auto_nation_capital: 1,
|
||||
|
||||
@@ -412,6 +412,13 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie
|
||||
expect(rowGeometry.gradientCount).toBe(0);
|
||||
await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0);
|
||||
await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible();
|
||||
await expect(page.getByRole('combobox', { name: '추방 대상 장수' }).locator('option')).toHaveText([
|
||||
'장수 선택',
|
||||
'하후돈 (70/70/70)',
|
||||
'곽가 (70/70/70)',
|
||||
'정욱 (70/70/70)',
|
||||
'장료 (70/70/70)',
|
||||
]);
|
||||
|
||||
await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click();
|
||||
const picker = page.getByTestId('personnel-selection-dialog');
|
||||
|
||||
@@ -92,6 +92,8 @@ const BASE_MAP_WIDTH = 700;
|
||||
const BASE_MAP_HEIGHT = 500;
|
||||
const SMALL_MAP_SCALE = 5 / 7;
|
||||
const MAP_BACKGROUND_TRANSITION_MS = 480;
|
||||
const TOOLTIP_FALLBACK_HEIGHT = 32;
|
||||
const TOOLTIP_VERTICAL_OFFSET = 30;
|
||||
|
||||
const decodedImageCache = new Map<string, Promise<void>>();
|
||||
const decodedImageElements = new Map<string, HTMLImageElement>();
|
||||
@@ -146,6 +148,7 @@ const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
||||
const mapArea = ref<HTMLElement | null>(null);
|
||||
const mapBody = ref<HTMLElement | null>(null);
|
||||
const mapControls = ref<HTMLElement | null>(null);
|
||||
const tooltipElement = ref<HTMLElement | null>(null);
|
||||
const mapOptionsOpen = ref(false);
|
||||
const mapOptionsMenuId = `map-options-${useId()}`;
|
||||
const { width: mapBodyWidth } = useElementSize(mapBody);
|
||||
@@ -568,10 +571,15 @@ const tooltipPosition = computed(() => {
|
||||
const width = 120;
|
||||
const offset = 10;
|
||||
const mapPixelWidth = BASE_MAP_WIDTH * mapScale.value;
|
||||
const mapPixelHeight = BASE_MAP_HEIGHT * mapScale.value;
|
||||
const tooltipHeight = tooltipElement.value?.offsetHeight ?? TOOLTIP_FALLBACK_HEIGHT;
|
||||
const left = elementX.value + width + offset > mapPixelWidth ? elementX.value - width - 5 : elementX.value + offset;
|
||||
const belowTop = elementY.value + TOOLTIP_VERTICAL_OFFSET;
|
||||
const top =
|
||||
belowTop + tooltipHeight > mapPixelHeight ? elementY.value - tooltipHeight - TOOLTIP_VERTICAL_OFFSET : belowTop;
|
||||
return {
|
||||
left: `${Math.max(0, left)}px`,
|
||||
top: `${elementY.value + 30}px`,
|
||||
top: `${Math.max(0, top)}px`,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -698,7 +706,7 @@ const selectCity = (cityId: number) => {
|
||||
>
|
||||
현재
|
||||
</div>
|
||||
<div v-if="hoveredCity" class="map-tooltip" :style="tooltipPosition">
|
||||
<div v-if="hoveredCity" ref="tooltipElement" class="map-tooltip" :style="tooltipPosition">
|
||||
<div class="tooltip-title">{{ hoveredCityTitle }}</div>
|
||||
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ type SettingForm = {
|
||||
use_treatment: number;
|
||||
use_auto_nation_turn: number;
|
||||
use_auto_nation_diplomacy: number;
|
||||
use_auto_nation_war: number;
|
||||
use_auto_nation_promotion: number;
|
||||
use_auto_nation_finance: number;
|
||||
use_auto_nation_capital: number;
|
||||
@@ -69,6 +70,7 @@ const form = reactive<SettingForm>({
|
||||
use_treatment: 10,
|
||||
use_auto_nation_turn: 1,
|
||||
use_auto_nation_diplomacy: 0,
|
||||
use_auto_nation_war: 0,
|
||||
use_auto_nation_promotion: 0,
|
||||
use_auto_nation_finance: 0,
|
||||
use_auto_nation_capital: 0,
|
||||
@@ -432,7 +434,16 @@ onMounted(() => {
|
||||
:true-value="1"
|
||||
:false-value="0"
|
||||
/>
|
||||
자동 외교 (불가침 제의·선전포고)
|
||||
자동 외교 (불가침 제의)
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
v-model="form.use_auto_nation_war"
|
||||
type="checkbox"
|
||||
:true-value="1"
|
||||
:false-value="0"
|
||||
/>
|
||||
자동 선전포고
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
|
||||
@@ -99,7 +99,9 @@ const cityCandidates = (level: OfficerLevel): GeneralEntry[] => {
|
||||
return candidates;
|
||||
};
|
||||
const kickCandidates = computed(() =>
|
||||
(data.value?.generals ?? []).filter((general) => general.id !== data.value?.me.id)
|
||||
(data.value?.generals ?? []).filter(
|
||||
(general) => general.id !== data.value?.me.id && general.officerLevel < 5 && general.permission !== 'ambassador'
|
||||
)
|
||||
);
|
||||
const awardText = (entries: PersonnelResponse['awards']['tigers']): string =>
|
||||
entries.map((entry) => `${entry.name}【${entry.value.toLocaleString('ko-KR')}】`).join(', ');
|
||||
|
||||
Reference in New Issue
Block a user