merge: 최신 main을 transport 권한과 durable 검증에 최종 통합한다

This commit is contained in:
2026-08-24 09:19:08 +00:00
57 changed files with 2203 additions and 225 deletions
+24 -1
View File
@@ -1,8 +1,8 @@
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
import type { DatabaseClient } from '../context.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
import { enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
export interface MessageView {
id: number;
@@ -203,3 +203,26 @@ export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Pro
},
});
};
export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Promise<void> => {
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return;
await db.$executeRaw(
GamePrisma.sql`
UPDATE message
SET message = jsonb_set(
jsonb_set(message, '{text}', to_jsonb(${'삭제된 메시지입니다.'}::text), true),
'{option}',
(
CASE
WHEN jsonb_typeof(message->'option') = 'object' THEN message->'option'
ELSE '{}'::jsonb
END
) || jsonb_build_object('invalid', true),
true
)
WHERE id IN (${GamePrisma.join(uniqueIds)})
`
);
};
+39 -9
View File
@@ -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),
@@ -693,7 +695,13 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
export const generalRouter = router({
adjustIcon: engineAuthedProcedure
.input(
z.object({ iconId: z.string().uuid().optional(), clientRequestId: z.string().uuid().optional() }).optional()
z
.object({
iconId: z.string().uuid().optional(),
resetToDefault: z.literal(true).optional(),
clientRequestId: z.string().uuid().optional(),
})
.optional()
)
.mutation(({ ctx, input }) => {
const userId = ctx.auth?.user.id;
@@ -701,19 +709,41 @@ export const generalRouter = router({
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const selected = input?.iconId ? ctx.auth?.user.icons?.find((icon) => icon.id === input.iconId) : undefined;
if (input?.iconId && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) {
const resetToDefault = input?.resetToDefault === true;
if (resetToDefault && input?.iconId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘 선택과 기본 아이콘 초기화를 함께 요청할 수 없습니다.' });
}
if (!resetToDefault && !input?.iconId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '적용할 활성 전용 아이콘을 선택해 주세요.' });
}
if (
resetToDefault &&
(ctx.auth?.user.picture !== 'default.jpg' || ctx.auth?.user.imageServer !== 0)
) {
throw new TRPCError({ code: 'FORBIDDEN', message: '현재 계정 아이콘이 기본 아이콘이 아닙니다.' });
}
if (!resetToDefault && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) {
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
}
const iconRevision = ctx.auth?.user.iconUpdatedAt ?? (resetToDefault ? undefined : selected!.createdAt);
if (!iconRevision) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '계정 아이콘 변경 시각을 확인할 수 없습니다.' });
}
const projection = resetToDefault
? {
picture: 'default.jpg',
imageServer: 0,
revision: iconRevision,
}
: {
picture: selected!.picture,
imageServer: selected!.imageServer,
revision: iconRevision,
};
return adjustAccountIconForUser(
ctx,
userId,
selected
? {
picture: selected.picture,
imageServer: selected.imageServer,
revision: ctx.auth?.user.iconUpdatedAt ?? selected.createdAt,
}
: undefined,
projection,
true,
input?.clientRequestId ?? ctx.requestId
);
+7 -6
View File
@@ -14,7 +14,6 @@ import {
WAR_TRAIT_KEYS,
} from '@sammo-ts/logic';
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { getSelectionPoolStatus, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
import {
@@ -515,15 +514,17 @@ export const joinRouter = router({
if (input.iconId && (!selectedIcon || auth.user.canUseGeneralPicture === false)) {
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
}
const accountIcon = input.pic
? selectedIcon
// 유저 장수에는 인증 token의 활성 전용 아이콘을 명시적으로 고른 경우만
// 그림을 적용한다. Gateway 대표 그림은 shared preset일 수 있으므로
// iconId 없는 fallback으로 사용하지 않는다.
const accountIcon =
input.pic && selectedIcon
? {
picture: selectedIcon.picture,
imageServer: selectedIcon.imageServer,
revision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt,
}
: await loadAuthoritativeAccountIcon(ctx, userId)
: null;
: null;
const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
const result = await requestJoinCreateCommand(ctx, {
type: 'joinCreateGeneral',
@@ -535,7 +536,7 @@ export const joinRouter = router({
leadership: input.leadership,
strength: input.strength,
intel: input.intel,
pic: input.pic,
pic: accountIcon !== null,
character: input.character,
profileId: ctx.profile.id,
...(accountIcon
+3 -7
View File
@@ -19,8 +19,8 @@ import {
fetchMessagesFromMailbox,
fetchOldMessagesFromMailbox,
fetchMessageById,
invalidateMessages,
insertMessage,
tombstoneMessages,
type MessageView,
} from '../../messages/store.js';
import { getOwnedGeneral } from '../shared/general.js';
@@ -40,11 +40,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
}
return {
...message,
text: '(외교 메시지입니다)',
option: {
...(message.option ?? {}),
invalid: true,
},
text: '조회 권한이 없는 외교 메시지입니다.',
};
});
};
@@ -303,7 +299,7 @@ export const messagesRouter = router({
message.id,
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
];
await invalidateMessages(ctx.db, ids);
await tombstoneMessages(ctx.db, ids);
const receiverMailbox =
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
? message.payload.dest.generalId
@@ -153,11 +153,17 @@ export const getPersonnelInfo = accessAuthedProcedure.query(async ({ ctx }) => {
const canChangePermissions = me.officerLevel === 12;
const ambassadors = canChangePermissions
? permissionCandidates.filter(
(candidate) => candidate.permission === 'ambassador' || candidate.maxPermission === 4
(candidate) =>
candidate.permission === 'ambassador' ||
(candidate.permission === 'normal' && candidate.maxPermission === 4)
)
: [];
const auditors = canChangePermissions
? permissionCandidates.filter((candidate) => candidate.permission === 'auditor' || candidate.maxPermission >= 3)
? permissionCandidates.filter(
(candidate) =>
candidate.permission === 'auditor' ||
(candidate.permission === 'normal' && candidate.maxPermission >= 3)
)
: [];
const generalNameMap = new Map(mappedGenerals.map((general) => [general.id, general.name]));
const awards = {
+1 -2
View File
@@ -433,7 +433,6 @@ export const voteRouter = router({
)
.mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx);
const openerName = ctx.auth?.user.username ?? general.name;
const options = normalizeOptions(input.options);
if (options.length === 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '항목이 없습니다.' });
@@ -488,7 +487,7 @@ export const voteRouter = router({
${multipleOptions},
${input.revealMode},
${general.id},
${openerName},
${general.name},
${gameTime.now},
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt},
+3 -5
View File
@@ -40,7 +40,7 @@ export const loadAuthoritativeAccountIcon = async (
export const adjustAccountIconForUser = async (
ctx: GameApiContext,
userId: string,
selected?: AccountIconProjection,
selected: AccountIconProjection,
enforceCooldown = true,
requestKey?: string
): Promise<{
@@ -48,10 +48,8 @@ export const adjustAccountIconForUser = async (
generalId: number | null;
updated: boolean;
}> => {
const projection = selected ?? (await loadAuthoritativeAccountIcon(ctx, userId));
const requestId = selected
? `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`
: `general:adjustIcon:${userId}:${projection.revision}`;
const projection = selected;
const requestId = `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`;
try {
const result = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralIcon',
@@ -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');
@@ -0,0 +1,84 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { tombstoneMessages } from '../src/messages/store.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
integration('message deletion tombstone persistence', () => {
let db: GamePrismaClient;
let close: (() => Promise<void>) | undefined;
beforeAll(async () => {
const schema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
if (!schema?.endsWith('conditional_integration')) {
throw new Error(`Unsafe schema: ${schema ?? '(missing)'}`);
}
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
close = () => connector.disconnect();
});
afterAll(async () => close?.());
it('keeps sender and receiver rows readable while replacing their bodies', async () => {
const rollback = new Error('rollback message tombstone fixture');
await expect(
db.$transaction(async (transaction) => {
const validUntil = new Date('9999-12-31T00:00:00.000Z');
const receiver = await transaction.message.create({
data: {
mailbox: 8,
type: 'private',
src: 7,
dest: 8,
time: new Date('2026-08-24T00:00:00.000Z'),
validUntil,
message: {
src: { generalId: 7 },
dest: { generalId: 8 },
text: '수신 사본 원문',
option: { senderMessageID: 0 },
},
},
});
const sender = await transaction.message.create({
data: {
mailbox: 7,
type: 'private',
src: 7,
dest: 8,
time: new Date('2026-08-24T00:00:00.000Z'),
validUntil,
message: {
src: { generalId: 7 },
dest: { generalId: 8 },
text: '송신 사본 원문',
option: { receiverMessageID: receiver.id },
},
},
});
await tombstoneMessages(transaction, [sender.id, receiver.id]);
const rows = await transaction.message.findMany({
where: { id: { in: [sender.id, receiver.id] } },
orderBy: { id: 'asc' },
});
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(row.validUntil).toEqual(validUntil);
expect(row.message).toMatchObject({
text: '삭제된 메시지입니다.',
option: { invalid: true },
});
expect(JSON.stringify(row.message)).not.toContain('사본 원문');
}
throw rollback;
})
).rejects.toBe(rollback);
});
});
+12 -14
View File
@@ -176,13 +176,15 @@ describe('messages router missing-flow compatibility', () => {
expect(recent.permission).toBe(2);
expect(recent.diplomacy[0]).toMatchObject({
text: '(외교 메시지입니다)',
option: { action: 'noAggression', invalid: true },
text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' },
});
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
expect(old.diplomacy[0]).toMatchObject({
text: '(외교 메시지입니다)',
option: { action: 'noAggression', invalid: true },
text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression' },
});
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
});
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
@@ -585,15 +587,13 @@ describe('messages router missing-flow compatibility', () => {
},
]);
const changeJournal = new ChangeJournal();
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
expect(result.deletedIds).toEqual([21, 22]);
expect(updateMany).toHaveBeenCalledWith({
where: { id: { in: [21, 22] } },
data: { validUntil: expect.any(Date) },
});
expect(executeRaw).toHaveBeenCalledOnce();
expect(updateMany).not.toHaveBeenCalled();
expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 7 },
{ domain: 'messages.mailbox', entityId: 8 },
@@ -632,15 +632,13 @@ describe('messages router missing-flow compatibility', () => {
},
},
]);
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw });
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw });
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
expect(result.deletedIds).toEqual([25]);
expect(updateMany).toHaveBeenCalledWith({
where: { id: { in: [25] } },
data: { validUntil: expect.any(Date) },
});
expect(executeRaw).toHaveBeenCalledOnce();
expect(updateMany).not.toHaveBeenCalled();
});
it('rejects deleting another general message', async () => {
@@ -230,6 +230,45 @@ describe('nation personnel router', () => {
expect(result.awards.eagles).toEqual([{ id: 30, name: '군사', value: 7 }]);
});
it('keeps ambassador and auditor candidate pools mutually exclusive like Ref', async () => {
const me = { ...baseGeneral, officerLevel: 12 };
const rows = [
listRow({ id: 22, name: '군주', officerLevel: 12 }),
listRow({ id: 30, name: '현 외교권자', meta: { belong: 5, permission: 'ambassador' } }),
listRow({ id: 31, name: '현 조언자', meta: { belong: 5, permission: 'auditor' } }),
listRow({ id: 32, name: '일반 후보' }),
listRow({ id: 33, name: '외교 금지', penalty: { noAmbassador: true } }),
];
const context = createContext({
me,
db: {
nation: {
findUnique: vi.fn(async () => ({
id: 1,
name: '위',
color: '#777777',
level: 3,
typeCode: 'che_법가',
capitalCityId: 1,
meta: { chief_set: 0 },
})),
},
city: { findMany: vi.fn(async () => []) },
troop: { findMany: vi.fn(async () => []) },
general: {
findFirst: vi.fn(async () => me),
findMany: vi.fn(async () => rows),
},
worldState: { findFirst: vi.fn(async () => ({ config: { stat: { chiefMin: 65 } } })) },
rankData: { findMany: vi.fn(async () => []) },
},
});
const result = await appRouter.createCaller(context).nation.getPersonnelInfo();
expect(result.permissionCandidates.ambassadors.map((candidate) => candidate.id)).toEqual([30, 32]);
expect(result.permissionCandidates.auditors.map((candidate) => candidate.id)).toEqual([31, 32]);
});
it('allows finance mutations only for a head officer or an eligible ambassador', async () => {
const nationDb = {
nation: {
+50 -39
View File
@@ -388,45 +388,56 @@ describe('appRouter', () => {
});
});
it('applies the current Gateway database icon instead of stale token claims', async () => {
it('rejects icon adjustment without an explicitly selected active icon', async () => {
const transport = new InMemoryTurnDaemonTransport();
const currentAccountIcon = {
revision: '2026-07-31T09:00:00.000Z',
picture: 'latest.png',
imageServer: 1,
};
const auth = buildAuth();
auth.user.picture = 'stale.png';
auth.user.picture = '장수/유비.jpg';
auth.user.imageServer = 0;
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
const requestId = `general:adjustIcon:${auth.user.id}:${currentAccountIcon.revision}`;
const accountIconGet = vi.fn(async () => ({
revision: '2026-07-31T09:00:00.000Z',
picture: '장수/유비.jpg',
imageServer: 0,
}));
const caller = appRouter.createCaller(
buildContext({
auth,
transport,
accountIconGet,
})
);
await expect(caller.general.adjustIcon()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await expect(caller.general.adjustIcon({ resetToDefault: true })).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect(accountIconGet).not.toHaveBeenCalled();
expect(transport.commands).toHaveLength(0);
});
it('allows an explicit default reset only when the signed account projection is default', async () => {
const transport = new InMemoryTurnDaemonTransport();
const auth = buildAuth();
const revision = '2026-07-31T09:00:00.000Z';
auth.user.picture = 'default.jpg';
auth.user.imageServer = 0;
auth.user.iconUpdatedAt = revision;
const requestId = `general:adjustIcon:${auth.user.id}:manual:${revision}:default.jpg`;
transport.setCommandResult(requestId, {
type: 'adjustGeneralIcon',
ok: true,
generalId: 1,
updated: true,
});
const caller = appRouter.createCaller(
buildContext({
auth,
transport,
currentAccountIcon,
})
);
const caller = appRouter.createCaller(buildContext({ auth, transport }));
await expect(caller.general.adjustIcon()).resolves.toEqual({
await expect(caller.general.adjustIcon({ resetToDefault: true })).resolves.toMatchObject({
ok: true,
generalId: 1,
updated: true,
});
expect(transport.commands.at(-1)?.command).toEqual({
type: 'adjustGeneralIcon',
expect(transport.commands.at(-1)?.command).toMatchObject({
requestId,
userId: auth.user.id,
picture: 'latest.png',
imageServer: 1,
iconRevision: currentAccountIcon.revision,
enforceCooldown: true,
picture: 'default.jpg',
imageServer: 0,
iconRevision: revision,
});
});
@@ -461,13 +472,13 @@ describe('appRouter', () => {
});
});
it('rejects icon adjustment without auth or a current Gateway account', async () => {
it('rejects icon adjustment without auth or a selected icon', async () => {
await expect(appRouter.createCaller(buildContext({ auth: null })).general.adjustIcon()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
await expect(
appRouter.createCaller(buildContext({ auth: buildAuth() })).general.adjustIcon()
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('rejects unauthenticated or game-blocked auth status checks', async () => {
@@ -581,30 +592,30 @@ describe('appRouter', () => {
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision');
});
it('uses the authoritative projection instead of stale token claims for picture creation', async () => {
it('does not apply a shared Gateway representative when no active icon id was selected', async () => {
const transport = new InMemoryTurnDaemonTransport();
const clientRequestId = '824454da-d0ab-48d2-a7d5-e2e5aaf83ba4';
const requestId = `join-create:user-1:${clientRequestId}`;
const revision = '2026-07-31T09:00:00.001Z';
transport.setCommandResult(requestId, {
type: 'joinCreateGeneral',
ok: true,
generalId: 42,
});
const auth = buildAuth();
auth.user.picture = 'stale.png';
auth.user.picture = '장수/유비.jpg';
auth.user.imageServer = 0;
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
const accountIconGet = vi.fn(async () => ({
revision: '2026-07-31T09:00:00.001Z',
picture: '장수/유비.jpg',
imageServer: 0,
}));
const caller = appRouter.createCaller(
buildContext({
state: buildWorldState(),
auth,
transport,
currentAccountIcon: {
revision,
picture: 'latest.png',
imageServer: 1,
},
accountIconGet,
})
);
@@ -618,11 +629,11 @@ describe('appRouter', () => {
clientRequestId,
});
expect(transport.commands.at(-1)?.command).toMatchObject({
ownerPicture: 'latest.png',
ownerImageServer: 1,
ownerIconRevision: revision,
});
expect(accountIconGet).not.toHaveBeenCalled();
expect(transport.commands.at(-1)?.command).toMatchObject({ pic: false });
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerPicture');
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerImageServer');
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision');
});
it('creates a general with the selected authenticated icon and rejects another icon id', async () => {
@@ -257,18 +257,25 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
const initial = await db.general.findFirstOrThrow({ where: { userId } });
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,
userId,
name: initial.name,
imageServer: initial.imageServer,
imageServer: 0,
picture: 'default.jpg',
stats: {
leadership: initial.leadership,
strength: initial.strength,
@@ -380,15 +387,15 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
intel: target.intel,
personalCode: initial.personalCode,
specialCode: target.specialDomestic,
imageServer: target.imageServer,
picture: target.picture,
imageServer: 0,
picture: 'default.jpg',
});
expect(runtime!.world.getGeneralById(initial.id)).toMatchObject({
id: initial.id,
userId,
name: target.generalName,
imageServer: target.imageServer,
picture: target.picture,
imageServer: 0,
picture: 'default.jpg',
stats: {
leadership: target.leadership,
strength: target.strength,
+4 -3
View File
@@ -267,7 +267,7 @@ describe('vote router actor and permission boundaries', () => {
expect(fixture.redisPublish).not.toHaveBeenCalled();
});
it('publishes a global front-status projection after creating a survey', async () => {
it('stores the authenticated general name and publishes a global projection after creating a survey', async () => {
const auth = buildAuth(['admin.survey.open']);
auth.user.username = 'admin-account';
auth.user.displayName = '관리자 표시명';
@@ -288,8 +288,9 @@ describe('vote router actor and permission boundaries', () => {
const insert = fixture.queryRaw.mock.calls
.map(([query]) => query)
.find((query) => sqlText(query).includes('INSERT INTO vote_poll'));
expect(insert?.values).toContain('admin-account');
expect(insert?.values).not.toContain('관리자 장수');
expect(insert?.values).toContain('관리자 장수');
expect(insert?.values).not.toContain('admin-account');
expect(insert?.values).not.toContain('관리자 표시명');
});
it('binds current operational timestamps in every raw SQL vote writer', async () => {
+3 -2
View File
@@ -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(),
+4 -1
View File
@@ -174,7 +174,10 @@ const processIncomeForNation = (
world.updateNation(nation.id, { rice: next, meta: nextMeta });
}
const incomeText = incomeValue.toLocaleString();
// Ref keeps the fractional pre-flush value for the payout ratio and
// prev_income_* metadata, but number_format() rounds the user-facing log
// to the same integer precision as the persisted resource column.
const incomeText = Math.round(incomeValue).toLocaleString('en-US');
const incomeLog =
type === 'gold' ? `이번 수입은 금 <C>${incomeText}</>입니다.` : `이번 수입은 쌀 <C>${incomeText}</>입니다.`;
for (const general of nationGenerals) {
@@ -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) {
+29 -10
View File
@@ -54,6 +54,18 @@ const DEFAULT_CREW_TYPE_ID = 1100;
const MAX_GENERAL_TURNS = 30;
const DEFAULT_TURN_ACTION = '휴식';
export const resolveSelectionPoolUserIcon = (options: {
showImgLevel: number;
ownerPicture?: string;
ownerImageServer?: number;
}): { picture: string; imageServer: number } => {
const useOwnerPicture =
options.showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg';
return useOwnerPicture
? { picture: options.ownerPicture!, imageServer: options.ownerImageServer ?? 1 }
: { picture: 'default.jpg', imageServer: 0 };
};
const zCandidateInfo = z.object({
uniqueName: z.string().min(1),
generalName: z.string().min(1),
@@ -690,6 +702,7 @@ export const createGeneralFromSelectionPool = async (options: {
uniqueName: string;
personality: string;
now?: Date;
operationalAcceptedAt: Date;
seedOwnerIdentity?: string | number;
ownerPicture?: string;
ownerImageServer?: number;
@@ -754,12 +767,15 @@ 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 showImgLevel = asNumber(config.showImgLevel, 0);
const useOwnerPicture =
showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg';
const picture = useOwnerPicture ? options.ownerPicture! : showImgLevel >= 3 ? info.picture : 'default.jpg';
const imageServer = useOwnerPicture ? (options.ownerImageServer ?? 1) : info.imgsvr;
const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config);
// 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는
// 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다.
const { picture, imageServer } = resolveSelectionPoolUserIcon({
showImgLevel: asNumber(config.showImgLevel, 0),
ownerPicture: options.ownerPicture,
ownerImageServer: options.ownerImageServer,
});
const useOwnerPicture = picture !== 'default.jpg';
const defaultSpecialWar =
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
const defaultSpecialDomestic =
@@ -893,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);
@@ -1022,6 +1038,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
now
),
};
const reselectionIcon = resolveSelectionPoolUserIcon({ showImgLevel: 0 });
const updated = world.updateGeneral(general.id, {
name: info.generalName,
stats: centennialGrowth?.stats ?? {
@@ -1035,8 +1052,10 @@ export const reselectGeneralFromSelectionPool = async (options: {
specialDomestic: info.specialDomestic,
specialWar: info.specialWar ?? general.role.specialWar,
},
picture: info.picture,
imageServer: info.imgsvr,
// 재선택 후보의 preset은 유저 장수에 이어 붙이지 않는다. 전용 아이콘을
// 다시 고르는 UI가 없는 현재 경로는 안전한 기본 아이콘으로 되돌린다.
picture: reselectionIcon.picture,
imageServer: reselectionIcon.imageServer,
meta: updatedMeta,
});
if (!updated) {
@@ -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', () => {
@@ -373,6 +373,13 @@ describe('core monthly event actions at the real month boundary', () => {
await world.advanceMonth(new Date('0191-01-01T00:00:00.000Z'));
expect(world.getNationById(1)?.meta.prev_income_gold).toBe(157.5);
expect(world.peekDirtyState().logs).toContainEqual(
expect.objectContaining({
generalId: 1,
text: '이번 수입은 금 <C>158</>입니다.',
})
);
expect(world.peekDirtyState().logs.map((entry) => entry.text).join('\n')).not.toContain('157.5');
});
it('uses the Ref default nation resource floors when scenario const omits them', async () => {
@@ -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,
@@ -4,7 +4,7 @@ import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { reserveSelectionPool } from '../src/turn/selectPoolService.js';
import { reserveSelectionPool, resolveSelectionPoolUserIcon } from '../src/turn/selectPoolService.js';
import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
interface TestPoolRow {
@@ -171,6 +171,27 @@ const worldState = {
};
describe('selection-pool reservation command state', () => {
it('uses only an explicitly selected owner icon for a human general', () => {
expect(resolveSelectionPoolUserIcon({ showImgLevel: 3 })).toEqual({
picture: 'default.jpg',
imageServer: 0,
});
expect(
resolveSelectionPoolUserIcon({
showImgLevel: 3,
ownerPicture: 'uploaded/user.png',
ownerImageServer: 1,
})
).toEqual({ picture: 'uploaded/user.png', imageServer: 1 });
expect(
resolveSelectionPoolUserIcon({
showImgLevel: 0,
ownerPicture: 'uploaded/user.png',
ownerImageServer: 1,
})
).toEqual({ picture: 'default.jpg', imageServer: 0 });
});
it('excludes current reservations and keeps serialized users disjoint in DB and memory', async () => {
const rows = buildRows();
const world = buildWorld(rows);
+40 -6
View File
@@ -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();
+45 -2
View File
@@ -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,
@@ -1981,6 +1990,40 @@ test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다'
expect(state.createGeneralInputs?.[0]).toMatchObject({ pic: true, iconId: secondIconId });
});
test('활성 전용 아이콘이 없으면 대표 preset을 장수 생성 요청에 전달하지 않는다', async ({ page }) => {
const state: FixtureState = {
permission: 'member',
myset: 1,
settingMutations: [],
accessPages: [],
createGeneralInputs: [],
joinConfig: {
rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true },
user: {
id: 'user-1',
displayName: '생성장수',
canCreateGeneral: true,
preferredPicture: '장수/유비.jpg',
icons: [],
},
personalities: [{ key: 'Random', name: '???', info: '무작위 성격' }],
nations: [],
selectionPool: { enabled: false },
npcPossession: { enabled: false },
inherit: null,
},
};
await install(page, state);
await page.goto('join');
await expect(page.getByRole('radiogroup', { name: '전용 아이콘 선택' })).toHaveCount(0);
await page.getByRole('button', { name: '장수 생성', exact: true }).last().click();
await expect.poll(() => state.createGeneralInputs?.length ?? 0).toBe(1);
expect(state.createGeneralInputs?.[0]).toMatchObject({ pic: false });
expect(state.createGeneralInputs?.[0]).not.toHaveProperty('iconId');
});
test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 새 ID를 만든다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
+7 -1
View File
@@ -23,6 +23,10 @@ const history = [
'<span class="war_type war_type_attack">→</span><span class="ev_highlight">강조</span>' +
'<span class="name" onclick="globalThis.__legacyLogXss=3">오염 이름</span></div>',
},
{
id: 3,
text: '이번 수입은 금 <C>158</>입니다.',
},
];
const publicResponse = (operation: string): unknown => {
@@ -59,10 +63,12 @@ for (const viewport of [
await page.goto('public');
const lines = page.locator('.recent-log-line');
await expect(lines).toHaveCount(2);
await expect(lines).toHaveCount(3);
await expect(lines.nth(0).locator('b')).toHaveText('안전 강조');
await expect(lines.nth(1).locator('.small_war_log .war_type_attack')).toHaveText('→');
await expect(lines.nth(1).locator('.ev_highlight')).toHaveText('강조');
await expect(lines.nth(2)).toHaveText('이번 수입은 금 158입니다.');
await expect(lines.nth(2)).not.toContainText('157.5');
await expect(lines.locator('script, img, svg, a, [onerror], [onclick], [style*="url"]')).toHaveCount(0);
await expect(lines.nth(0)).toContainText('<img src=x onerror=');
await expect(lines.nth(1)).toContainText('<span class="name" onclick=');
+82 -2
View File
@@ -14,6 +14,7 @@ type FixtureState = {
appointedGeneralId?: number;
appointedCityId?: number;
appointedOfficerLevel?: number;
permissionMutationInput?: { isAmbassador: boolean; targetGeneralIds: number[] };
noticeMutationInput?: string;
scoutMutationInput?: string;
uploadDataUrl?: string;
@@ -101,6 +102,8 @@ const personnelFixture = (state: FixtureState) => {
general(5, '정욱', 2),
general(6, '장료', 1),
general(7, '허저', 1, { permission: 'ambassador' }),
general(8, '가후', 1, { permission: 'auditor' }),
general(9, '전위', 1),
];
const visibleGenerals =
state.role === 'member' ? fullGenerals.filter((entry) => entry.officerLevel >= 2) : fullGenerals;
@@ -145,8 +148,13 @@ const personnelFixture = (state: FixtureState) => {
ambassadors: [
{ id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 },
{ id: 7, name: '허저', npcState: 0, permission: 'ambassador', maxPermission: 4 },
{ id: 9, name: '전위', npcState: 0, permission: 'normal', maxPermission: 4 },
],
auditors: [
{ id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 },
{ id: 8, name: '가후', npcState: 0, permission: 'auditor', maxPermission: 4 },
{ id: 9, name: '전위', npcState: 0, permission: 'normal', maxPermission: 4 },
],
auditors: [{ id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 }],
}
: { ambassadors: [], auditors: [] },
};
@@ -238,7 +246,16 @@ const installFixture = async (page: Page, state: FixtureState) => {
state.appointedOfficerLevel = Number(jsonInput.officerLevel ?? 0);
return response({ ok: true });
}
if (operation === 'nation.kick' || operation === 'nation.changePermission') return response({ ok: true });
if (operation === 'nation.changePermission') {
state.permissionMutationInput = {
isAmbassador: jsonInput.isAmbassador === true,
targetGeneralIds: Array.isArray(jsonInput.targetGeneralIds)
? jsonInput.targetGeneralIds.map((id) => Number(id))
: [],
};
return response({ ok: true });
}
if (operation === 'nation.kick') return response({ ok: true });
if (operation === 'nation.setRate') {
if (state.failNextRate) {
state.failNextRate = false;
@@ -347,6 +364,62 @@ test('personnel keeps the desktop frame while exposing row-level appointment con
await screenshot(page, 'core-personnel-desktop-leader.png');
});
test('leader can grant two ambassador and auditor permissions by click or touch without modifier keys', async ({
page,
}) => {
const state: FixtureState = { role: 'leader', rate: 20 };
await installFixture(page, state);
await page.setViewportSize({ width: 390, height: 844 });
await gotoOffice(page, 'nation/personnel');
const ambassadorTrigger = page.locator('.permission-multiselect-trigger').first();
await expect(ambassadorTrigger).toHaveAccessibleName('외교권자 선택, 현재 1명');
await ambassadorTrigger.click();
const ambassadorOptions = page.getByRole('listbox', { name: '외교권자 후보' });
await expect(ambassadorOptions).toBeVisible();
await expect(ambassadorOptions.getByRole('option', { name: '허저' })).toHaveAttribute('aria-selected', 'true');
await ambassadorOptions.getByRole('option', { name: '장료' }).click();
await expect(ambassadorOptions.getByRole('option', { name: '장료' })).toHaveAttribute('aria-selected', 'true');
await expect(ambassadorTrigger).toHaveAccessibleName('외교권자 선택, 현재 2명');
await ambassadorOptions.getByRole('option', { name: '전위' }).click();
await expect(page.getByTestId('game-toast')).toContainText('최대 2명까지 설정 가능합니다.');
await expect(ambassadorOptions.getByRole('option', { name: '전위' })).toHaveAttribute('aria-selected', 'false');
const ambassadorGeometry = await ambassadorOptions.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, width: rect.width };
});
expect(ambassadorGeometry.left).toBeGreaterThanOrEqual(0);
expect(ambassadorGeometry.right).toBeLessThanOrEqual(390);
expect(ambassadorGeometry.width).toBeGreaterThanOrEqual(100);
await screenshot(page, 'core-personnel-mobile-permission-picker-open.png');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('외교권자를 변경할까요?');
await dialog.accept();
});
await page.getByRole('button', { name: '외교권자 임명 반영' }).click();
await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible();
await expect.poll(() => state.permissionMutationInput).toEqual({ isAmbassador: true, targetGeneralIds: [7, 6] });
const auditorTrigger = page.locator('.permission-multiselect-trigger').nth(1);
await expect(auditorTrigger).toHaveAccessibleName('조언자 선택, 현재 1명');
await auditorTrigger.click();
const auditorOptions = page.getByRole('listbox', { name: '조언자 후보' });
await expect(auditorOptions.getByRole('option', { name: '허저' })).toHaveCount(0);
await auditorOptions.getByRole('option', { name: '장료' }).click();
await expect(auditorTrigger).toHaveAccessibleName('조언자 선택, 현재 2명');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('조언자를 변경할까요?');
await dialog.accept();
});
await page.getByRole('button', { name: '조언자 임명 반영' }).click();
await expect(page.getByTestId('game-toast').filter({ hasText: '권한을 변경했습니다.' })).toBeVisible();
await expect.poll(() => state.permissionMutationInput).toEqual({ isAmbassador: false, targetGeneralIds: [8, 6] });
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
await screenshot(page, 'core-personnel-mobile-permission-picker.png');
});
test('personnel selects an informed general and reports the JosaUtil-composed result in a toast', async ({ page }) => {
const state: FixtureState = { role: 'head', rate: 20 };
await installFixture(page, state);
@@ -412,6 +485,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');
+39 -6
View File
@@ -375,19 +375,52 @@ test('physical mobile touch reorders NPC priority across active and inactive lis
}
});
test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => {
const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] };
test('a read-level user may edit drafts but cannot reset, revert, or submit them', async ({ page }) => {
const state: FixtureState = { permissionLevel: 1, mutations: [] };
await installFixture(page, state);
await gotoPolicy(page);
const input = page.getByLabel('국가 권장 금');
await expect(input).toBeEnabled();
await input.fill('23456');
page.once('dialog', (dialog) => dialog.accept());
await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click();
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.');
const nationPanel = page.locator('.priority-panel').first();
const activePriority = nationPanel.locator('.priority-column').nth(1).getByText('불가침제의');
const inactivePriorityList = nationPanel.locator('.priority-column').first().locator('.priority-list');
await activePriority.dragTo(inactivePriorityList);
await expect(inactivePriorityList.getByText('불가침제의')).toBeVisible();
const actionButtons = page.locator('.control_bar button');
await expect(actionButtons).toHaveCount(9);
for (const button of await actionButtons.all()) {
await expect(button).toBeDisabled();
}
const disabledStyle = await actionButtons.first().evaluate((element) => {
const style = getComputedStyle(element);
return { cursor: style.cursor, opacity: style.opacity, filter: style.filter };
});
expect(disabledStyle).toEqual({ cursor: 'not-allowed', opacity: '0.55', filter: 'none' });
// The handler guard is independent of the disabled DOM attribute. This also
// protects callers that dispatch a click after permission data has changed.
for (const button of await page.locator('#container > .control_bar button').all()) {
await button.evaluate((element) => {
element.removeAttribute('disabled');
(element as HTMLButtonElement).click();
});
}
for (const button of await nationPanel.locator('.control_bar button').all()) {
await button.evaluate((element) => {
element.removeAttribute('disabled');
(element as HTMLButtonElement).click();
});
}
await expect(input).toHaveValue('23456');
expect(state.mutations).toEqual(['npc.setNationPolicy']);
await expect(inactivePriorityList.getByText('불가침제의')).toBeVisible();
expect(state.mutations).toEqual([]);
await screenshot(page, 'core-npc-policy-read-only-controls.png');
});
test('a user below secret read permission receives a recoverable page error', async ({ page }) => {
@@ -429,6 +429,7 @@ test.describe('scenario 903 live selection pool', () => {
expect(created.name).toBe(initialName?.trim());
expect(created.personalCode).toBe('che_안전');
expect(created.specialCode).toMatch(/^che_event_/);
expect(created).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
const createEvent = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'selectPoolCreate' },
orderBy: { sequence: 'desc' },
@@ -508,6 +509,12 @@ test.describe('scenario 903 live selection pool', () => {
await expect
.poll(async () => (await db.general.findUniqueOrThrow({ where: { id: created.id } })).name)
.toBe(targetName);
await expect
.poll(async () => {
const general = await db.general.findUniqueOrThrow({ where: { id: created.id } });
return { picture: general.picture, imageServer: general.imageServer };
})
.toEqual({ picture: 'default.jpg', imageServer: 0 });
const reselectEvent = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'selectPoolReselect' },
orderBy: { sequence: 'desc' },
@@ -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>
@@ -0,0 +1,232 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
type PermissionCandidate = {
id: number;
name: string;
};
const props = withDefaults(
defineProps<{
modelValue: number[];
candidates: PermissionCandidate[];
label: string;
max?: number;
}>(),
{ max: 2 }
);
const emit = defineEmits<{
'update:modelValue': [value: number[]];
limit: [];
}>();
const root = ref<HTMLElement | null>(null);
const open = ref(false);
const selectedCandidates = computed(() => {
const selected = new Set(props.modelValue);
return props.candidates.filter((candidate) => selected.has(candidate.id));
});
const toggleOpen = (): void => {
open.value = !open.value;
};
const toggleCandidate = (id: number): void => {
if (props.modelValue.includes(id)) {
emit(
'update:modelValue',
props.modelValue.filter((selectedId) => selectedId !== id)
);
return;
}
if (props.modelValue.length >= props.max) {
emit('limit');
return;
}
emit('update:modelValue', [...props.modelValue, id]);
};
const handleDocumentPointerDown = (event: PointerEvent): void => {
if (!root.value?.contains(event.target as Node)) open.value = false;
};
const handleKeydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault();
open.value = false;
}
};
onMounted(() => document.addEventListener('pointerdown', handleDocumentPointerDown));
onBeforeUnmount(() => document.removeEventListener('pointerdown', handleDocumentPointerDown));
</script>
<template>
<div ref="root" class="permission-multiselect" @keydown="handleKeydown">
<button
type="button"
class="permission-multiselect-trigger"
:aria-label="`${label} 선택, 현재 ${modelValue.length}명`"
aria-haspopup="listbox"
:aria-expanded="open"
@click="toggleOpen"
>
<span v-if="selectedCandidates.length" class="permission-multiselect-values">
<span v-for="candidate in selectedCandidates" :key="candidate.id">{{ candidate.name }}</span>
</span>
<span v-else class="permission-multiselect-placeholder">선택 </span>
<span class="permission-multiselect-arrow" aria-hidden="true"></span>
</button>
<div
v-if="open"
class="permission-multiselect-options"
role="listbox"
aria-multiselectable="true"
:aria-label="`${label} 후보`"
>
<button
v-for="candidate in candidates"
:key="candidate.id"
type="button"
class="permission-multiselect-option"
role="option"
:aria-selected="modelValue.includes(candidate.id)"
@click="toggleCandidate(candidate.id)"
>
<span class="permission-multiselect-check" aria-hidden="true">
{{ modelValue.includes(candidate.id) ? '✓' : '' }}
</span>
<span>{{ candidate.name }}</span>
</button>
<p v-if="candidates.length === 0" class="permission-multiselect-empty">임명 가능한 장수가 없습니다.</p>
<p class="permission-multiselect-help">클릭해서 선택·해제 · 최대 {{ max }}</p>
</div>
</div>
</template>
<style scoped>
.permission-multiselect {
position: relative;
display: inline-block;
width: 300px;
max-width: calc(100% - 58px);
vertical-align: middle;
}
.permission-multiselect-trigger {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
width: 100%;
min-height: 34px;
border: 1px solid #858585;
border-radius: 4px;
padding: 3px 7px;
color: #fff;
background: #000;
font: inherit;
text-align: left;
cursor: pointer;
}
.permission-multiselect-trigger:hover,
.permission-multiselect-trigger[aria-expanded='true'] {
border-color: #b9b9b9;
}
.permission-multiselect-trigger:focus-visible,
.permission-multiselect-option:focus-visible {
outline: 2px solid #fff;
outline-offset: 1px;
}
.permission-multiselect-values {
display: flex;
flex-wrap: wrap;
gap: 3px;
min-width: 0;
}
.permission-multiselect-values > span {
overflow: hidden;
max-width: 126px;
border-radius: 3px;
padding: 2px 5px;
color: #fff;
background: #4d4d4d;
text-overflow: ellipsis;
white-space: nowrap;
}
.permission-multiselect-placeholder {
color: #aaa;
}
.permission-multiselect-arrow {
margin-left: 5px;
color: #ccc;
}
.permission-multiselect-options {
position: absolute;
z-index: 40;
top: calc(100% + 2px);
left: 0;
width: 100%;
max-height: 220px;
overflow-y: auto;
border: 1px solid #858585;
border-radius: 3px;
color: #fff;
background: #101010;
box-shadow: 0 5px 14px rgb(0 0 0 / 70%);
}
.permission-multiselect-option {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 5px;
align-items: center;
width: 100%;
border: 0;
border-bottom: 1px solid #353535;
border-radius: 0;
padding: 7px 8px;
color: #fff;
background: #101010;
font: inherit;
text-align: left;
cursor: pointer;
}
.permission-multiselect-option:hover,
.permission-multiselect-option[aria-selected='true'] {
background: #424242;
}
.permission-multiselect-check {
display: grid;
width: 16px;
height: 16px;
place-items: center;
border: 1px solid #aaa;
border-radius: 2px;
color: #111;
background: #fff;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.permission-multiselect-empty,
.permission-multiselect-help {
margin: 0;
padding: 6px 8px;
color: #bbb;
font-size: 11px;
}
.permission-multiselect-help {
border-top: 1px solid #454545;
}
@media (max-width: 620px) {
.permission-multiselect {
width: calc(100% - 54px);
max-width: none;
}
.permission-multiselect-values > span {
max-width: 82px;
}
.permission-multiselect-option {
min-height: 38px;
}
}
</style>
+2 -1
View File
@@ -64,7 +64,7 @@ const form = ref<JoinForm>({
strength: 0,
intel: 0,
character: 'Random',
pic: true,
pic: false,
iconId: undefined,
inheritBonusStat: [0, 0, 0],
});
@@ -437,6 +437,7 @@ const loadConfig = async () => {
} else {
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위';
form.value.iconId = config.user.icons.find((icon) => icon.picture === config.user.preferredPicture)?.id;
form.value.pic = form.value.iconId !== undefined;
applyBalancedStats();
}
} catch (err) {
+12 -1
View File
@@ -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
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import PermissionMultiSelect from '../components/personnel/PermissionMultiSelect.vue';
import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon';
@@ -99,7 +100,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(', ');
@@ -249,9 +252,7 @@ const applySelection = async (id: number): Promise<void> => {
else await appointCityOfficer(context.level, context.cityId, id);
};
const enforcePermissionLimit = (selection: number[]) => {
if (selection.length <= 2) return;
selection.splice(0, selection.length - 2);
const reportPermissionLimit = () => {
showErrorToast('최대 2명까지 설정 가능합니다.');
};
@@ -388,23 +389,16 @@ onMounted(() => void loadPersonnel());
<tr>
<td class="green-cell permission-label">외교권자</td>
<td>
<select
<PermissionMultiSelect
v-model="ambassadorSelection"
multiple
aria-label="외교권자"
@change="enforcePermissionLimit(ambassadorSelection)"
>
<option
v-for="candidate in data.permissionCandidates.ambassadors"
:key="candidate.id"
:value="candidate.id"
>
{{ candidate.name }}
</option>
</select>
label="외교권자"
:candidates="data.permissionCandidates.ambassadors"
@limit="reportPermissionLimit"
/>
<button
class="legacy-button legacy-button--primary"
type="button"
aria-label="외교권자 임명 반영"
@click="changePermissions(true)"
>
임명
@@ -412,23 +406,16 @@ onMounted(() => void loadPersonnel());
</td>
<td class="green-cell permission-label">조언자</td>
<td>
<select
<PermissionMultiSelect
v-model="auditorSelection"
multiple
aria-label="조언자"
@change="enforcePermissionLimit(auditorSelection)"
>
<option
v-for="candidate in data.permissionCandidates.auditors"
:key="candidate.id"
:value="candidate.id"
>
{{ candidate.name }}
</option>
</select>
label="조언자"
:candidates="data.permissionCandidates.auditors"
@limit="reportPermissionLimit"
/>
<button
class="legacy-button legacy-button--primary"
type="button"
aria-label="조언자 임명 반영"
@click="changePermissions(false)"
>
임명
@@ -664,10 +651,6 @@ select {
background: #000;
font: inherit;
}
select[multiple] {
width: 300px;
height: 34px;
}
.nation-heading {
height: 32px;
text-align: center;
+43 -13
View File
@@ -46,6 +46,7 @@ const generalPriority = ref<PriorityListState | null>(null);
const lastSavedNationPriority = ref<string[]>([]);
const lastSavedGeneralPriority = ref<string[]>([]);
const { success: showSuccessToast, error: showErrorToast, info: showInfoToast } = useGameFeedback();
const canManagePolicy = computed(() => (data.value?.permissionLevel ?? -1) >= 3);
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) return value.message;
@@ -282,19 +283,19 @@ const priorityPanels = computed<PriorityPanel[]>(() => {
});
const resetPolicy = () => {
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
if (!canManagePolicy.value || !data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
policyDraft.value = clonePolicy(data.value.defaultNationPolicy);
showInfoToast('서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.');
};
const rollbackPolicy = () => {
if (!lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
if (!canManagePolicy.value || !lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
policyDraft.value = clonePolicy(lastSavedPolicy.value);
showInfoToast('이전 설정으로 되돌렸습니다.');
};
const submitPolicy = async () => {
if (!policyDraft.value || !window.confirm('저장할까요?')) return;
if (!canManagePolicy.value || !policyDraft.value || !window.confirm('저장할까요?')) return;
try {
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
lastSavedPolicy.value = clonePolicy(policyDraft.value);
@@ -305,7 +306,7 @@ const submitPolicy = async () => {
};
const resetPriority = (section: PrioritySectionKey) => {
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
if (!canManagePolicy.value || !data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
if (section === 'nation') {
nationPriority.value = assignPriorityState(
data.value.defaultNationPriority,
@@ -321,7 +322,7 @@ const resetPriority = (section: PrioritySectionKey) => {
};
const rollbackPriority = (section: PrioritySectionKey) => {
if (!data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
if (!canManagePolicy.value || !data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
if (section === 'nation') {
nationPriority.value = assignPriorityState(
lastSavedNationPriority.value,
@@ -338,7 +339,7 @@ const rollbackPriority = (section: PrioritySectionKey) => {
const submitPriority = async (section: PrioritySectionKey) => {
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!state || !window.confirm('저장할까요?')) return;
if (!canManagePolicy.value || !state || !window.confirm('저장할까요?')) return;
try {
if (section === 'nation') {
await trpc.npc.setNationPriority.mutate(state.active);
@@ -416,10 +417,16 @@ const submitPriority = async (section: PrioritySectionKey) => {
<div class="control_bar">
<div class="button-group">
<button class="reset_btn" type="button" @click="resetPolicy">초깃값으로</button>
<button class="revert_btn" type="button" @click="rollbackPolicy">이전값으로</button>
<button class="reset_btn" type="button" :disabled="!canManagePolicy" @click="resetPolicy">
초깃값으로
</button>
<button class="revert_btn" type="button" :disabled="!canManagePolicy" @click="rollbackPolicy">
이전값으로
</button>
</div>
<button class="submit_btn" type="button" @click="submitPolicy">설정</button>
<button class="submit_btn" type="button" :disabled="!canManagePolicy" @click="submitPolicy">
설정
</button>
</div>
<div class="priority-sections">
@@ -498,14 +505,31 @@ const submitPriority = async (section: PrioritySectionKey) => {
</div>
<div class="control_bar priority-control">
<div class="button-group">
<button class="reset_btn" type="button" @click="resetPriority(panel.key)">
<button
class="reset_btn"
type="button"
:disabled="!canManagePolicy"
@click="resetPriority(panel.key)"
>
초깃값으로
</button>
<button class="revert_btn" type="button" @click="rollbackPriority(panel.key)">
<button
class="revert_btn"
type="button"
:disabled="!canManagePolicy"
@click="rollbackPriority(panel.key)"
>
이전값으로
</button>
</div>
<button class="submit_btn" type="button" @click="submitPriority(panel.key)">설정</button>
<button
class="submit_btn"
type="button"
:disabled="!canManagePolicy"
@click="submitPriority(panel.key)"
>
설정
</button>
</div>
</section>
</div>
@@ -728,10 +752,16 @@ const submitPriority = async (section: PrioritySectionKey) => {
border-radius: 4px;
}
.control_bar button:hover {
.control_bar button:not(:disabled):hover {
filter: brightness(1.15);
}
.control_bar button:disabled {
cursor: not-allowed;
opacity: 0.55;
filter: none;
}
.control_bar button:focus-visible,
.help-button:focus-visible {
outline: 2px solid #fff;
@@ -429,7 +429,7 @@ onBeforeUnmount(() => {
<th class="legacy-bg1">전콘 선택</th>
<td class="pool-icon-choice">
<label>
<input v-model="selectedIconId" type="radio" value="" /> 선택한 장수
<input v-model="selectedIconId" type="radio" value="" /> 기본 아이
</label>
<label v-for="icon in config.user.icons" :key="icon.id">
<input v-model="selectedIconId" type="radio" :value="icon.id" />
@@ -40,6 +40,16 @@ const profileInputAt = (body: string, index: number): string | null => {
}
};
const adjustIconInputAt = (body: string, index: number): Record<string, unknown> => {
try {
const parsed = JSON.parse(body) as Record<string, Record<string, unknown> & { json?: Record<string, unknown> }>;
const input = parsed[String(index)];
return input?.json ?? input ?? {};
} catch {
return {};
}
};
type FixtureOptions = {
failHweAdjustOnce?: boolean;
delayHweAdjust?: boolean;
@@ -65,6 +75,7 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
let preferredIconCount = 0;
let retireIconCount = 0;
let hweAdjustCount = 0;
const adjustIconInputs: Array<Record<string, unknown>> = [];
const operations = new Map<string, string[]>([
['che:903', []],
['hwe:903', []],
@@ -199,7 +210,8 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
`/${profileName.split(':')[0]}/api/trpc/${operationNames(route).join(',')}`
);
const results = [];
for (const operation of operationNames(route)) {
const body = route.request().postData() ?? '';
for (const [index, operation] of operationNames(route).entries()) {
if (operation === 'auth.exchangeGatewayToken') {
operations.get(profileName)?.push('exchangeGatewayToken');
results.push(
@@ -213,6 +225,11 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
}
if (operation === 'general.adjustIcon') {
operations.get(profileName)?.push('adjustIcon');
const input = adjustIconInputAt(body, index);
adjustIconInputs.push(input);
if (input.resetToDefault !== true) {
expect(input.iconId).toBe('3f804277-584f-4f44-b39c-9ecf40d1ed31');
}
if (profileName === 'hwe:903') {
hweAdjustCount += 1;
if (options.delayHweAdjust) {
@@ -242,6 +259,7 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
deleteIconCount: () => deleteIconCount,
preferredIconCount: () => preferredIconCount,
retireIconCount: () => retireIconCount,
adjustIconInputs: () => adjustIconInputs,
};
};
@@ -558,6 +576,9 @@ test('uses the Ref delete confirmation and opens the modal only after acceptance
.toBe('none');
await page.keyboard.press('Escape');
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
await apply.click();
await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('적용됨');
expect(fixture.adjustIconInputs().at(-1)).toEqual({ resetToDefault: true });
});
test('contains focus and long failure content inside a 320px viewport', async ({ page }) => {
+10 -1
View File
@@ -442,7 +442,16 @@ const syncIconToServer = async (row: IconSyncRow, token: string): Promise<void>
gatewayToken: issued.gameToken,
});
const gameTrpc = createGameTrpc(row.profile, row.apiPort, exchanged.accessToken);
await gameTrpc.general.adjustIcon.mutate();
if (!account.value?.iconUrl) {
await gameTrpc.general.adjustIcon.mutate({ resetToDefault: true });
row.state = 'success';
return;
}
const selectedIconId = account.value?.icons.find(
(icon) => icon.picture === account.value?.preferredPicture
)?.id;
if (!selectedIconId) throw new Error('적용할 활성 전용 아이콘을 찾을 수 없습니다.');
await gameTrpc.general.adjustIcon.mutate({ iconId: selectedIconId });
row.state = 'success';
} catch (error) {
row.state = 'error';