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 { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
import type { DatabaseClient } from '../context.js'; import type { DatabaseClient } from '../context.js';
import { loadCurrentGameTime } from '../services/gameClock.js'; import { loadCurrentGameTime } from '../services/gameClock.js';
import { enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
export interface MessageView { export interface MessageView {
id: number; 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_treatment: z.number().int().optional(),
use_auto_nation_turn: 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_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_promotion: z.number().int().min(0).max(1).optional(),
use_auto_nation_finance: 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(), 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이다. // Ref가 NPC 군주에게만 수행하던 국가 운영은 사용자 군주에게 opt-in이다.
// 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다. // 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다.
use_auto_nation_diplomacy: readNumber(readSetting('use_auto_nation_diplomacy'), 0), 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_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0),
use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0), use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0),
use_auto_nation_capital: readNumber(readSetting('use_auto_nation_capital'), 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({ export const generalRouter = router({
adjustIcon: engineAuthedProcedure adjustIcon: engineAuthedProcedure
.input( .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 }) => { .mutation(({ ctx, input }) => {
const userId = ctx.auth?.user.id; const userId = ctx.auth?.user.id;
@@ -701,19 +709,41 @@ export const generalRouter = router({
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const selected = input?.iconId ? ctx.auth?.user.icons?.find((icon) => icon.id === input.iconId) : undefined; 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: '사용 가능한 내 전용 아이콘이 아닙니다.' }); 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( return adjustAccountIconForUser(
ctx, ctx,
userId, userId,
selected projection,
? {
picture: selected.picture,
imageServer: selected.imageServer,
revision: ctx.auth?.user.iconUpdatedAt ?? selected.createdAt,
}
: undefined,
true, true,
input?.clientRequestId ?? ctx.requestId input?.clientRequestId ?? ctx.requestId
); );
+7 -6
View File
@@ -14,7 +14,6 @@ import {
WAR_TRAIT_KEYS, WAR_TRAIT_KEYS,
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js'; import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
import { loadCurrentGameTime } from '../../services/gameClock.js'; import { loadCurrentGameTime } from '../../services/gameClock.js';
import { getSelectionPoolStatus, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js'; import { getSelectionPoolStatus, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
import { import {
@@ -515,15 +514,17 @@ export const joinRouter = router({
if (input.iconId && (!selectedIcon || auth.user.canUseGeneralPicture === false)) { if (input.iconId && (!selectedIcon || auth.user.canUseGeneralPicture === false)) {
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' }); throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
} }
const accountIcon = input.pic // 유저 장수에는 인증 token의 활성 전용 아이콘을 명시적으로 고른 경우만
? selectedIcon // 그림을 적용한다. Gateway 대표 그림은 shared preset일 수 있으므로
// iconId 없는 fallback으로 사용하지 않는다.
const accountIcon =
input.pic && selectedIcon
? { ? {
picture: selectedIcon.picture, picture: selectedIcon.picture,
imageServer: selectedIcon.imageServer, imageServer: selectedIcon.imageServer,
revision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt, revision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt,
} }
: await loadAuthoritativeAccountIcon(ctx, userId) : null;
: null;
const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId); const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
const result = await requestJoinCreateCommand(ctx, { const result = await requestJoinCreateCommand(ctx, {
type: 'joinCreateGeneral', type: 'joinCreateGeneral',
@@ -535,7 +536,7 @@ export const joinRouter = router({
leadership: input.leadership, leadership: input.leadership,
strength: input.strength, strength: input.strength,
intel: input.intel, intel: input.intel,
pic: input.pic, pic: accountIcon !== null,
character: input.character, character: input.character,
profileId: ctx.profile.id, profileId: ctx.profile.id,
...(accountIcon ...(accountIcon
+3 -7
View File
@@ -19,8 +19,8 @@ import {
fetchMessagesFromMailbox, fetchMessagesFromMailbox,
fetchOldMessagesFromMailbox, fetchOldMessagesFromMailbox,
fetchMessageById, fetchMessageById,
invalidateMessages,
insertMessage, insertMessage,
tombstoneMessages,
type MessageView, type MessageView,
} from '../../messages/store.js'; } from '../../messages/store.js';
import { getOwnedGeneral } from '../shared/general.js'; import { getOwnedGeneral } from '../shared/general.js';
@@ -40,11 +40,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
} }
return { return {
...message, ...message,
text: '(외교 메시지입니다)', text: '조회 권한이 없는 외교 메시지입니다.',
option: {
...(message.option ?? {}),
invalid: true,
},
}; };
}); });
}; };
@@ -303,7 +299,7 @@ export const messagesRouter = router({
message.id, message.id,
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []), ...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
]; ];
await invalidateMessages(ctx.db, ids); await tombstoneMessages(ctx.db, ids);
const receiverMailbox = const receiverMailbox =
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private' shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
? message.payload.dest.generalId ? message.payload.dest.generalId
@@ -153,11 +153,17 @@ export const getPersonnelInfo = accessAuthedProcedure.query(async ({ ctx }) => {
const canChangePermissions = me.officerLevel === 12; const canChangePermissions = me.officerLevel === 12;
const ambassadors = canChangePermissions const ambassadors = canChangePermissions
? permissionCandidates.filter( ? permissionCandidates.filter(
(candidate) => candidate.permission === 'ambassador' || candidate.maxPermission === 4 (candidate) =>
candidate.permission === 'ambassador' ||
(candidate.permission === 'normal' && candidate.maxPermission === 4)
) )
: []; : [];
const auditors = canChangePermissions 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 generalNameMap = new Map(mappedGenerals.map((general) => [general.id, general.name]));
const awards = { const awards = {
+1 -2
View File
@@ -433,7 +433,6 @@ export const voteRouter = router({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const general = await getMyGeneral(ctx); const general = await getMyGeneral(ctx);
const openerName = ctx.auth?.user.username ?? general.name;
const options = normalizeOptions(input.options); const options = normalizeOptions(input.options);
if (options.length === 0) { if (options.length === 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '항목이 없습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '항목이 없습니다.' });
@@ -488,7 +487,7 @@ export const voteRouter = router({
${multipleOptions}, ${multipleOptions},
${input.revealMode}, ${input.revealMode},
${general.id}, ${general.id},
${openerName}, ${general.name},
${gameTime.now}, ${gameTime.now},
${gameTime.tick === null ? null : BigInt(gameTime.tick)}, ${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt}, ${endAt},
+3 -5
View File
@@ -40,7 +40,7 @@ export const loadAuthoritativeAccountIcon = async (
export const adjustAccountIconForUser = async ( export const adjustAccountIconForUser = async (
ctx: GameApiContext, ctx: GameApiContext,
userId: string, userId: string,
selected?: AccountIconProjection, selected: AccountIconProjection,
enforceCooldown = true, enforceCooldown = true,
requestKey?: string requestKey?: string
): Promise<{ ): Promise<{
@@ -48,10 +48,8 @@ export const adjustAccountIconForUser = async (
generalId: number | null; generalId: number | null;
updated: boolean; updated: boolean;
}> => { }> => {
const projection = selected ?? (await loadAuthoritativeAccountIcon(ctx, userId)); const projection = selected;
const requestId = selected const requestId = `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`;
? `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`
: `general:adjustIcon:${userId}:${projection.revision}`;
try { try {
const result = await ctx.turnDaemon.requestCommand({ const result = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralIcon', type: 'adjustGeneralIcon',
@@ -287,12 +287,16 @@ integration('generic general creation through the durable turn daemon', () => {
accountIconUpdatedAt: '2026-07-30T00:00:00.000Z', accountIconUpdatedAt: '2026-07-30T00:00:00.000Z',
}); });
const createdAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: created.id } }); 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) { if (!createdAccess.lastRefresh) {
throw new Error('created general must have an initial access timestamp'); throw new Error('created general must have an initial access timestamp');
} }
expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
expect( expect(
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() - 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); ).toBe(2 * 5 * 60 * 1_000);
expect(runtime!.world.getGeneralById(created.id)).toMatchObject({ expect(runtime!.world.getGeneralById(created.id)).toMatchObject({
id: created.id, id: created.id,
@@ -354,7 +358,7 @@ integration('generic general creation through the durable turn daemon', () => {
attempts: 1, attempts: 1,
actorUserId: userId, actorUserId: userId,
}); });
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime()); expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
const turnGridOffsetSeconds = const turnGridOffsetSeconds =
((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300; ((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300;
expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35); expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35);
@@ -585,6 +585,7 @@ describe('in-game my information ownership', () => {
use_treatment: 21, use_treatment: 21,
use_auto_nation_turn: 1, use_auto_nation_turn: 1,
use_auto_nation_diplomacy: 0, use_auto_nation_diplomacy: 0,
use_auto_nation_war: 0,
use_auto_nation_promotion: 0, use_auto_nation_promotion: 0,
use_auto_nation_finance: 0, use_auto_nation_finance: 0,
use_auto_nation_capital: 0, use_auto_nation_capital: 0,
@@ -600,6 +601,16 @@ describe('in-game my information ownership', () => {
expect(fixture.db.general.update).not.toHaveBeenCalled(); 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 () => { it('sends settings directly to ENGINE without creating an API input event', async () => {
const transaction = vi.fn(async () => { const transaction = vi.fn(async () => {
throw new Error('API transaction must not run'); 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.permission).toBe(2);
expect(recent.diplomacy[0]).toMatchObject({ expect(recent.diplomacy[0]).toMatchObject({
text: '(외교 메시지입니다)', text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression', invalid: true }, option: { action: 'noAggression' },
}); });
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
expect(old.diplomacy[0]).toMatchObject({ expect(old.diplomacy[0]).toMatchObject({
text: '(외교 메시지입니다)', text: '조회 권한이 없는 외교 메시지입니다.',
option: { action: 'noAggression', invalid: true }, 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 () => { 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 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 }); const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
expect(result.deletedIds).toEqual([21, 22]); expect(result.deletedIds).toEqual([21, 22]);
expect(updateMany).toHaveBeenCalledWith({ expect(executeRaw).toHaveBeenCalledOnce();
where: { id: { in: [21, 22] } }, expect(updateMany).not.toHaveBeenCalled();
data: { validUntil: expect.any(Date) },
});
expect(changeJournal.snapshot()).toEqual([ expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 7 }, { domain: 'messages.mailbox', entityId: 7 },
{ domain: 'messages.mailbox', entityId: 8 }, { 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 }); const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
expect(result.deletedIds).toEqual([25]); expect(result.deletedIds).toEqual([25]);
expect(updateMany).toHaveBeenCalledWith({ expect(executeRaw).toHaveBeenCalledOnce();
where: { id: { in: [25] } }, expect(updateMany).not.toHaveBeenCalled();
data: { validUntil: expect.any(Date) },
});
}); });
it('rejects deleting another general message', async () => { 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 }]); 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 () => { it('allows finance mutations only for a head officer or an eligible ambassador', async () => {
const nationDb = { const nationDb = {
nation: { 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 transport = new InMemoryTurnDaemonTransport();
const currentAccountIcon = {
revision: '2026-07-31T09:00:00.000Z',
picture: 'latest.png',
imageServer: 1,
};
const auth = buildAuth(); const auth = buildAuth();
auth.user.picture = 'stale.png'; auth.user.picture = '장수/유비.jpg';
auth.user.imageServer = 0; auth.user.imageServer = 0;
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z'; 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, { transport.setCommandResult(requestId, {
type: 'adjustGeneralIcon', type: 'adjustGeneralIcon',
ok: true, ok: true,
generalId: 1, generalId: 1,
updated: true, updated: true,
}); });
const caller = appRouter.createCaller( const caller = appRouter.createCaller(buildContext({ auth, transport }));
buildContext({
auth,
transport,
currentAccountIcon,
})
);
await expect(caller.general.adjustIcon()).resolves.toEqual({ await expect(caller.general.adjustIcon({ resetToDefault: true })).resolves.toMatchObject({
ok: true, ok: true,
generalId: 1,
updated: true, updated: true,
}); });
expect(transport.commands.at(-1)?.command).toEqual({ expect(transport.commands.at(-1)?.command).toMatchObject({
type: 'adjustGeneralIcon',
requestId, requestId,
userId: auth.user.id, picture: 'default.jpg',
picture: 'latest.png', imageServer: 0,
imageServer: 1, iconRevision: revision,
iconRevision: currentAccountIcon.revision,
enforceCooldown: true,
}); });
}); });
@@ -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({ await expect(appRouter.createCaller(buildContext({ auth: null })).general.adjustIcon()).rejects.toMatchObject({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
}); });
await expect( await expect(
appRouter.createCaller(buildContext({ auth: buildAuth() })).general.adjustIcon() 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 () => { 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'); 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 transport = new InMemoryTurnDaemonTransport();
const clientRequestId = '824454da-d0ab-48d2-a7d5-e2e5aaf83ba4'; const clientRequestId = '824454da-d0ab-48d2-a7d5-e2e5aaf83ba4';
const requestId = `join-create:user-1:${clientRequestId}`; const requestId = `join-create:user-1:${clientRequestId}`;
const revision = '2026-07-31T09:00:00.001Z';
transport.setCommandResult(requestId, { transport.setCommandResult(requestId, {
type: 'joinCreateGeneral', type: 'joinCreateGeneral',
ok: true, ok: true,
generalId: 42, generalId: 42,
}); });
const auth = buildAuth(); const auth = buildAuth();
auth.user.picture = 'stale.png'; auth.user.picture = '장수/유비.jpg';
auth.user.imageServer = 0; auth.user.imageServer = 0;
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z'; 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( const caller = appRouter.createCaller(
buildContext({ buildContext({
state: buildWorldState(), state: buildWorldState(),
auth, auth,
transport, transport,
currentAccountIcon: { accountIconGet,
revision,
picture: 'latest.png',
imageServer: 1,
},
}) })
); );
@@ -618,11 +629,11 @@ describe('appRouter', () => {
clientRequestId, clientRequestId,
}); });
expect(transport.commands.at(-1)?.command).toMatchObject({ expect(accountIconGet).not.toHaveBeenCalled();
ownerPicture: 'latest.png', expect(transport.commands.at(-1)?.command).toMatchObject({ pic: false });
ownerImageServer: 1, expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerPicture');
ownerIconRevision: revision, 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 () => { 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 initial = await db.general.findFirstOrThrow({ where: { userId } });
const initialRuntime = runtime!.world.getGeneralById(initial.id); const initialRuntime = runtime!.world.getGeneralById(initial.id);
const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: 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) { if (!initialAccess.lastRefresh) {
throw new Error('selected general must have an initial access timestamp'); throw new Error('selected general must have an initial access timestamp');
} }
expect(initialAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
expect( expect(
new Date((initial.meta as Record<string, unknown>).prestart_delete_after as string).getTime() - 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); ).toBe(2 * 5 * 60 * 1_000);
expect(initialRuntime).toMatchObject({ expect(initialRuntime).toMatchObject({
id: initial.id, id: initial.id,
userId, userId,
name: initial.name, name: initial.name,
imageServer: initial.imageServer, imageServer: 0,
picture: 'default.jpg',
stats: { stats: {
leadership: initial.leadership, leadership: initial.leadership,
strength: initial.strength, strength: initial.strength,
@@ -380,15 +387,15 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
intel: target.intel, intel: target.intel,
personalCode: initial.personalCode, personalCode: initial.personalCode,
specialCode: target.specialDomestic, specialCode: target.specialDomestic,
imageServer: target.imageServer, imageServer: 0,
picture: target.picture, picture: 'default.jpg',
}); });
expect(runtime!.world.getGeneralById(initial.id)).toMatchObject({ expect(runtime!.world.getGeneralById(initial.id)).toMatchObject({
id: initial.id, id: initial.id,
userId, userId,
name: target.generalName, name: target.generalName,
imageServer: target.imageServer, imageServer: 0,
picture: target.picture, picture: 'default.jpg',
stats: { stats: {
leadership: target.leadership, leadership: target.leadership,
strength: target.strength, strength: target.strength,
+4 -3
View File
@@ -267,7 +267,7 @@ describe('vote router actor and permission boundaries', () => {
expect(fixture.redisPublish).not.toHaveBeenCalled(); 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']); const auth = buildAuth(['admin.survey.open']);
auth.user.username = 'admin-account'; auth.user.username = 'admin-account';
auth.user.displayName = '관리자 표시명'; auth.user.displayName = '관리자 표시명';
@@ -288,8 +288,9 @@ describe('vote router actor and permission boundaries', () => {
const insert = fixture.queryRaw.mock.calls const insert = fixture.queryRaw.mock.calls
.map(([query]) => query) .map(([query]) => query)
.find((query) => sqlText(query).includes('INSERT INTO vote_poll')); .find((query) => sqlText(query).includes('INSERT INTO vote_poll'));
expect(insert?.values).toContain('admin-account'); expect(insert?.values).toContain('관리자 장수');
expect(insert?.values).not.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 () => { 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, NPC전방발령: true,
}; };
export type UserRulerAutomationFeature = 'diplomacy' | 'promotion' | 'finance' | 'capital'; export type UserRulerAutomationFeature = 'diplomacy' | 'war' | 'promotion' | 'finance' | 'capital';
const USER_RULER_AUTOMATION_META_KEY = { const USER_RULER_AUTOMATION_META_KEY = {
diplomacy: 'use_auto_nation_diplomacy', diplomacy: 'use_auto_nation_diplomacy',
war: 'use_auto_nation_war',
promotion: 'use_auto_nation_promotion', promotion: 'use_auto_nation_promotion',
finance: 'use_auto_nation_finance', finance: 'use_auto_nation_finance',
capital: 'use_auto_nation_capital', capital: 'use_auto_nation_capital',
@@ -72,7 +73,7 @@ const USER_RULER_AUTOMATION_META_KEY = {
const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeature>> = { const USER_RULER_ACTION_FEATURE: Readonly<Record<string, UserRulerAutomationFeature>> = {
: 'diplomacy', : 'diplomacy',
: 'diplomacy', : 'war',
: 'capital', : 'capital',
}; };
@@ -137,6 +137,7 @@ const zSetMySetting = z.object({
use_treatment: z.number().int().optional(), use_treatment: z.number().int().optional(),
use_auto_nation_turn: z.number().int().optional(), use_auto_nation_turn: z.number().int().optional(),
use_auto_nation_diplomacy: 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_promotion: z.number().int().optional(),
use_auto_nation_finance: z.number().int().optional(), use_auto_nation_finance: z.number().int().optional(),
use_auto_nation_capital: 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 }); 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 = const incomeLog =
type === 'gold' ? `이번 수입은 금 <C>${incomeText}</>입니다.` : `이번 수입은 쌀 <C>${incomeText}</>입니다.`; type === 'gold' ? `이번 수입은 금 <C>${incomeText}</>입니다.` : `이번 수입은 쌀 <C>${incomeText}</>입니다.`;
for (const general of nationGenerals) { for (const general of nationGenerals) {
@@ -522,8 +522,9 @@ export const createGeneralFromJoin = async (options: {
worldState: WorldStateRow; worldState: WorldStateRow;
input: JoinCreateGeneralInput; input: JoinCreateGeneralInput;
acceptedAt: Date; acceptedAt: Date;
operationalAcceptedAt: Date;
}): Promise<{ ok: true; generalId: number }> => { }): 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 lockJoinMutation(db, input.userId);
await assertGeneralIdSnapshotMatches(db, world); await assertGeneralIdSnapshotMatches(db, world);
@@ -734,7 +735,10 @@ export const createGeneralFromJoin = async (options: {
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint; const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId); const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId);
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus; 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 = { const general: TurnGeneral = {
id: generalId, id: generalId,
userId: input.userId, userId: input.userId,
@@ -839,11 +843,11 @@ export const createGeneralFromJoin = async (options: {
}); });
await db.generalAccessLog.upsert({ await db.generalAccessLog.upsert({
where: { generalId }, where: { generalId },
update: { userId: input.userId, lastRefresh: acceptedAt }, update: { userId: input.userId, lastRefresh: operationalAcceptedAt },
create: { create: {
generalId, generalId,
userId: input.userId, userId: input.userId,
lastRefresh: acceptedAt, lastRefresh: operationalAcceptedAt,
}, },
}); });
if (inheritRequiredPoint > 0) { if (inheritRequiredPoint > 0) {
+29 -10
View File
@@ -54,6 +54,18 @@ const DEFAULT_CREW_TYPE_ID = 1100;
const MAX_GENERAL_TURNS = 30; const MAX_GENERAL_TURNS = 30;
const DEFAULT_TURN_ACTION = '휴식'; 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({ const zCandidateInfo = z.object({
uniqueName: z.string().min(1), uniqueName: z.string().min(1),
generalName: z.string().min(1), generalName: z.string().min(1),
@@ -690,6 +702,7 @@ export const createGeneralFromSelectionPool = async (options: {
uniqueName: string; uniqueName: string;
personality: string; personality: string;
now?: Date; now?: Date;
operationalAcceptedAt: Date;
seedOwnerIdentity?: string | number; seedOwnerIdentity?: string | number;
ownerPicture?: string; ownerPicture?: string;
ownerImageServer?: number; ownerImageServer?: number;
@@ -754,12 +767,15 @@ export const createGeneralFromSelectionPool = async (options: {
const nextChangeAt = new Date( const nextChangeAt = new Date(
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000 now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
); );
const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config); const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config);
const showImgLevel = asNumber(config.showImgLevel, 0); // 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는
const useOwnerPicture = // 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다.
showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg'; const { picture, imageServer } = resolveSelectionPoolUserIcon({
const picture = useOwnerPicture ? options.ownerPicture! : showImgLevel >= 3 ? info.picture : 'default.jpg'; showImgLevel: asNumber(config.showImgLevel, 0),
const imageServer = useOwnerPicture ? (options.ownerImageServer ?? 1) : info.imgsvr; ownerPicture: options.ownerPicture,
ownerImageServer: options.ownerImageServer,
});
const useOwnerPicture = picture !== 'default.jpg';
const defaultSpecialWar = const defaultSpecialWar =
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None'; typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
const defaultSpecialDomestic = const defaultSpecialDomestic =
@@ -893,8 +909,8 @@ export const createGeneralFromSelectionPool = async (options: {
} }
await db.generalAccessLog.upsert({ await db.generalAccessLog.upsert({
where: { generalId }, where: { generalId },
update: { userId, lastRefresh: now }, update: { userId, lastRefresh: options.operationalAcceptedAt },
create: { generalId, userId, lastRefresh: now }, create: { generalId, userId, lastRefresh: options.operationalAcceptedAt },
}); });
await clearUnusedReservations(db, userId, now, nowTick); await clearUnusedReservations(db, userId, now, nowTick);
await synchronizeSelectionPoolWorld(db, world); await synchronizeSelectionPoolWorld(db, world);
@@ -1022,6 +1038,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
now now
), ),
}; };
const reselectionIcon = resolveSelectionPoolUserIcon({ showImgLevel: 0 });
const updated = world.updateGeneral(general.id, { const updated = world.updateGeneral(general.id, {
name: info.generalName, name: info.generalName,
stats: centennialGrowth?.stats ?? { stats: centennialGrowth?.stats ?? {
@@ -1035,8 +1052,10 @@ export const reselectGeneralFromSelectionPool = async (options: {
specialDomestic: info.specialDomestic, specialDomestic: info.specialDomestic,
specialWar: info.specialWar ?? general.role.specialWar, specialWar: info.specialWar ?? general.role.specialWar,
}, },
picture: info.picture, // 재선택 후보의 preset은 유저 장수에 이어 붙이지 않는다. 전용 아이콘을
imageServer: info.imgsvr, // 다시 고르는 UI가 없는 현재 경로는 안전한 기본 아이콘으로 되돌린다.
picture: reselectionIcon.picture,
imageServer: reselectionIcon.imageServer,
meta: updatedMeta, meta: updatedMeta,
}); });
if (!updated) { if (!updated) {
@@ -296,6 +296,7 @@ async function handleJoinCreateGeneral(
...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}), ...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}),
}, },
acceptedAt, acceptedAt,
operationalAcceptedAt,
})), })),
}; };
} catch (error) { } catch (error) {
@@ -366,7 +367,13 @@ async function handleSelectPoolCreate(
if (!worldState) { if (!worldState) {
throw new Error('Selection-pool world state is missing.'); 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 { try {
return { return {
type: 'selectPoolCreate', type: 'selectPoolCreate',
@@ -383,6 +390,7 @@ async function handleSelectPoolCreate(
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}), ...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}), ...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}),
now: acceptedAt, now: acceptedAt,
operationalAcceptedAt,
})), })),
}; };
} catch (error) { } catch (error) {
@@ -1779,6 +1787,7 @@ async function handleSetMySetting(
} }
for (const key of [ for (const key of [
'use_auto_nation_diplomacy', 'use_auto_nation_diplomacy',
'use_auto_nation_war',
'use_auto_nation_promotion', 'use_auto_nation_promotion',
'use_auto_nation_finance', 'use_auto_nation_finance',
'use_auto_nation_capital', 'use_auto_nation_capital',
@@ -1988,7 +1997,7 @@ async function handleKick(
} }
const target = world.getGeneralById(command.destGeneralId); const target = world.getGeneralById(command.destGeneralId);
if (!target || target.id === general.id || target.nationId !== general.nationId) { if (!target || target.nationId !== general.nationId) {
return { return {
type: 'kick', type: 'kick',
ok: false, ok: false,
@@ -1996,7 +2005,18 @@ async function handleKick(
reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.', 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 { return {
type: 'kick', type: 'kick',
ok: false, 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', () => { it('keeps user-ruler duties individually disabled until each setting is enabled', () => {
const ruler = makePromotionGeneral({ id: 1, officerLevel: 12, npcState: 0, meta: { killturn: 0 } }); 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(canUseAutomatedNationAction(ruler, '천도')).toBe(false); expect(canUseAutomatedNationAction(ruler, '천도')).toBe(false);
expect(canUseRulerAutomation(ruler, 'finance')).toBe(false); expect(canUseRulerAutomation(ruler, 'finance')).toBe(false);
@@ -728,9 +729,17 @@ describe('legacy NPC user-chief promotion parity', () => {
use_auto_nation_finance: 1, use_auto_nation_finance: 1,
}; };
expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(true); expect(canUseAutomatedNationAction(ruler, '불가침제의')).toBe(true);
expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(true); expect(canUseAutomatedNationAction(ruler, '선전포고')).toBe(false);
expect(canUseAutomatedNationAction(ruler, '천도')).toBe(true); expect(canUseAutomatedNationAction(ruler, '천도')).toBe(true);
expect(canUseRulerAutomation(ruler, 'finance')).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', () => { 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')); await world.advanceMonth(new Date('0191-01-01T00:00:00.000Z'));
expect(world.getNationById(1)?.meta.prev_income_gold).toBe(157.5); 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 () => { 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_treatment: 200,
use_auto_nation_turn: 0, use_auto_nation_turn: 0,
use_auto_nation_diplomacy: 1, use_auto_nation_diplomacy: 1,
use_auto_nation_war: 1,
use_auto_nation_promotion: 1, use_auto_nation_promotion: 1,
use_auto_nation_finance: 1, use_auto_nation_finance: 1,
use_auto_nation_capital: 1, use_auto_nation_capital: 1,
@@ -239,6 +240,7 @@ describe('my information world commands', () => {
use_treatment: 100, use_treatment: 100,
use_auto_nation_turn: 0, use_auto_nation_turn: 0,
use_auto_nation_diplomacy: 1, use_auto_nation_diplomacy: 1,
use_auto_nation_war: 1,
use_auto_nation_promotion: 1, use_auto_nation_promotion: 1,
use_auto_nation_finance: 1, use_auto_nation_finance: 1,
use_auto_nation_capital: 1, use_auto_nation_capital: 1,
@@ -312,6 +312,39 @@ describe('nation personnel world commands', () => {
expect(fixture.world.peekDirtyState().logs).toHaveLength(2); 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 () => { it('preserves the legacy kick year boundaries and deterministic NPC public message', async () => {
const early = buildWorld({ const early = buildWorld({
currentYear: 181, currentYear: 181,
@@ -4,7 +4,7 @@ import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic'; import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; 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'; import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
interface TestPoolRow { interface TestPoolRow {
@@ -171,6 +171,27 @@ const worldState = {
}; };
describe('selection-pool reservation command state', () => { 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 () => { it('excludes current reservations and keeps serialized users disjoint in DB and memory', async () => {
const rows = buildRows(); const rows = buildRows();
const world = buildWorld(rows); 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: 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: 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: 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: 5, name: '남만', level: 4, layoutLevel: 8, x: 80, y: 455, width: 20, height: 15 },
{ id: 6, level: 5, layoutLevel: 8, x: 200, y: 220, width: 24, height: 16 }, { id: 6, name: '교지', level: 5, layoutLevel: 8, x: 130, y: 480, width: 24, height: 16 },
{ id: 7, level: 6, layoutLevel: 8, x: 300, y: 220, width: 26, height: 18 }, { id: 7, name: '남해', level: 6, layoutLevel: 8, x: 245, y: 480, width: 26, height: 18 },
{ id: 8, level: 7, layoutLevel: 8, x: 400, y: 220, width: 28, height: 20 }, { id: 8, name: '대', level: 7, layoutLevel: 8, x: 450, y: 480, width: 28, height: 20 },
] as const; ] as const;
const map = { const map = {
result: true, result: true,
@@ -82,9 +82,9 @@ const map = {
}; };
const layout = { const layout = {
mapName: 'che', mapName: 'che',
cityList: castleFixtures.map(({ id, layoutLevel: level, x, y }) => ({ cityList: castleFixtures.map(({ id, layoutLevel: level, x, y, ...fixture }) => ({
id, id,
name: id === 1 ? '업' : `${id}`, name: id === 1 ? '업' : 'name' in fixture ? fixture.name : `${id}`,
level, level,
region: 1, region: 1,
x, 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 expect(page.locator('.map-toggle-single-tap')).toHaveCount(0);
await desktopCity.hover(); await desktopCity.hover();
await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업'); 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 desktopCity.click();
await expect(page).toHaveURL(/\/current-city\?cityId=1$/u); await expect(page).toHaveURL(/\/current-city\?cityId=1$/u);
await page.goBack(); await page.goBack();
+45 -2
View File
@@ -276,6 +276,7 @@ const myGeneral = (state: FixtureState) => ({
use_treatment: 21, use_treatment: 21,
use_auto_nation_turn: 1, use_auto_nation_turn: 1,
use_auto_nation_diplomacy: 0, use_auto_nation_diplomacy: 0,
use_auto_nation_war: 0,
use_auto_nation_promotion: 0, use_auto_nation_promotion: 0,
use_auto_nation_finance: 0, use_auto_nation_finance: 0,
use_auto_nation_capital: 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'); const rulerAutomation = page.locator('.ruler-automation-settings');
await expect(rulerAutomation).toBeVisible(); 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 promotionAutomation = page.getByRole('checkbox', { name: '자동 수뇌 임명' });
const financeAutomation = page.getByRole('checkbox', { name: '자동 세율·지급률 조정' }); const financeAutomation = page.getByRole('checkbox', { name: '자동 세율·지급률 조정' });
const capitalAutomation = 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 expect(checkbox).not.toBeChecked();
await checkbox.check(); 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]).not.toHaveProperty('generalId');
expect(state.settingMutations[0]).toMatchObject({ expect(state.settingMutations[0]).toMatchObject({
use_auto_nation_diplomacy: 1, use_auto_nation_diplomacy: 1,
use_auto_nation_war: 1,
use_auto_nation_promotion: 1, use_auto_nation_promotion: 1,
use_auto_nation_finance: 1, use_auto_nation_finance: 1,
use_auto_nation_capital: 1, use_auto_nation_capital: 1,
@@ -1981,6 +1990,40 @@ test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다'
expect(state.createGeneralInputs?.[0]).toMatchObject({ pic: true, iconId: secondIconId }); 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 }) => { test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 새 ID를 만든다', async ({ page }) => {
const state: FixtureState = { const state: FixtureState = {
permission: 'head', 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="war_type war_type_attack">→</span><span class="ev_highlight">강조</span>' +
'<span class="name" onclick="globalThis.__legacyLogXss=3">오염 이름</span></div>', '<span class="name" onclick="globalThis.__legacyLogXss=3">오염 이름</span></div>',
}, },
{
id: 3,
text: '이번 수입은 금 <C>158</>입니다.',
},
]; ];
const publicResponse = (operation: string): unknown => { const publicResponse = (operation: string): unknown => {
@@ -59,10 +63,12 @@ for (const viewport of [
await page.goto('public'); await page.goto('public');
const lines = page.locator('.recent-log-line'); 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(0).locator('b')).toHaveText('안전 강조');
await expect(lines.nth(1).locator('.small_war_log .war_type_attack')).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(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.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(0)).toContainText('<img src=x onerror=');
await expect(lines.nth(1)).toContainText('<span class="name" onclick='); await expect(lines.nth(1)).toContainText('<span class="name" onclick=');
+82 -2
View File
@@ -14,6 +14,7 @@ type FixtureState = {
appointedGeneralId?: number; appointedGeneralId?: number;
appointedCityId?: number; appointedCityId?: number;
appointedOfficerLevel?: number; appointedOfficerLevel?: number;
permissionMutationInput?: { isAmbassador: boolean; targetGeneralIds: number[] };
noticeMutationInput?: string; noticeMutationInput?: string;
scoutMutationInput?: string; scoutMutationInput?: string;
uploadDataUrl?: string; uploadDataUrl?: string;
@@ -101,6 +102,8 @@ const personnelFixture = (state: FixtureState) => {
general(5, '정욱', 2), general(5, '정욱', 2),
general(6, '장료', 1), general(6, '장료', 1),
general(7, '허저', 1, { permission: 'ambassador' }), general(7, '허저', 1, { permission: 'ambassador' }),
general(8, '가후', 1, { permission: 'auditor' }),
general(9, '전위', 1),
]; ];
const visibleGenerals = const visibleGenerals =
state.role === 'member' ? fullGenerals.filter((entry) => entry.officerLevel >= 2) : fullGenerals; state.role === 'member' ? fullGenerals.filter((entry) => entry.officerLevel >= 2) : fullGenerals;
@@ -145,8 +148,13 @@ const personnelFixture = (state: FixtureState) => {
ambassadors: [ ambassadors: [
{ id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 }, { id: 6, name: '장료', npcState: 0, permission: 'normal', maxPermission: 4 },
{ id: 7, name: '허저', npcState: 0, permission: 'ambassador', 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: [] }, : { ambassadors: [], auditors: [] },
}; };
@@ -238,7 +246,16 @@ const installFixture = async (page: Page, state: FixtureState) => {
state.appointedOfficerLevel = Number(jsonInput.officerLevel ?? 0); state.appointedOfficerLevel = Number(jsonInput.officerLevel ?? 0);
return response({ ok: true }); 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 (operation === 'nation.setRate') {
if (state.failNextRate) { if (state.failNextRate) {
state.failNextRate = false; 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'); 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 }) => { test('personnel selects an informed general and reports the JosaUtil-composed result in a toast', async ({ page }) => {
const state: FixtureState = { role: 'head', rate: 20 }; const state: FixtureState = { role: 'head', rate: 20 };
await installFixture(page, state); 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); expect(rowGeometry.gradientCount).toBe(0);
await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0); await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0);
await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible(); 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(); await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click();
const picker = page.getByTestId('personnel-selection-dialog'); 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 }) => { test('a read-level user may edit drafts but cannot reset, revert, or submit them', async ({ page }) => {
const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] }; const state: FixtureState = { permissionLevel: 1, mutations: [] };
await installFixture(page, state); await installFixture(page, state);
await gotoPolicy(page); await gotoPolicy(page);
const input = page.getByLabel('국가 권장 금'); const input = page.getByLabel('국가 권장 금');
await expect(input).toBeEnabled(); await expect(input).toBeEnabled();
await input.fill('23456'); await input.fill('23456');
page.once('dialog', (dialog) => dialog.accept());
await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); const nationPanel = page.locator('.priority-panel').first();
await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); 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'); 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 }) => { 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.name).toBe(initialName?.trim());
expect(created.personalCode).toBe('che_안전'); expect(created.personalCode).toBe('che_안전');
expect(created.specialCode).toMatch(/^che_event_/); expect(created.specialCode).toMatch(/^che_event_/);
expect(created).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
const createEvent = await db.inputEvent.findFirstOrThrow({ const createEvent = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'selectPoolCreate' }, where: { actorUserId: userId, eventType: 'selectPoolCreate' },
orderBy: { sequence: 'desc' }, orderBy: { sequence: 'desc' },
@@ -508,6 +509,12 @@ test.describe('scenario 903 live selection pool', () => {
await expect await expect
.poll(async () => (await db.general.findUniqueOrThrow({ where: { id: created.id } })).name) .poll(async () => (await db.general.findUniqueOrThrow({ where: { id: created.id } })).name)
.toBe(targetName); .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({ const reselectEvent = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'selectPoolReselect' }, where: { actorUserId: userId, eventType: 'selectPoolReselect' },
orderBy: { sequence: 'desc' }, orderBy: { sequence: 'desc' },
@@ -92,6 +92,8 @@ const BASE_MAP_WIDTH = 700;
const BASE_MAP_HEIGHT = 500; const BASE_MAP_HEIGHT = 500;
const SMALL_MAP_SCALE = 5 / 7; const SMALL_MAP_SCALE = 5 / 7;
const MAP_BACKGROUND_TRANSITION_MS = 480; const MAP_BACKGROUND_TRANSITION_MS = 480;
const TOOLTIP_FALLBACK_HEIGHT = 32;
const TOOLTIP_VERTICAL_OFFSET = 30;
const decodedImageCache = new Map<string, Promise<void>>(); const decodedImageCache = new Map<string, Promise<void>>();
const decodedImageElements = new Map<string, HTMLImageElement>(); const decodedImageElements = new Map<string, HTMLImageElement>();
@@ -146,6 +148,7 @@ const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
const mapArea = ref<HTMLElement | null>(null); const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null); const mapBody = ref<HTMLElement | null>(null);
const mapControls = ref<HTMLElement | null>(null); const mapControls = ref<HTMLElement | null>(null);
const tooltipElement = ref<HTMLElement | null>(null);
const mapOptionsOpen = ref(false); const mapOptionsOpen = ref(false);
const mapOptionsMenuId = `map-options-${useId()}`; const mapOptionsMenuId = `map-options-${useId()}`;
const { width: mapBodyWidth } = useElementSize(mapBody); const { width: mapBodyWidth } = useElementSize(mapBody);
@@ -568,10 +571,15 @@ const tooltipPosition = computed(() => {
const width = 120; const width = 120;
const offset = 10; const offset = 10;
const mapPixelWidth = BASE_MAP_WIDTH * mapScale.value; 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 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 { return {
left: `${Math.max(0, left)}px`, 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>
<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-title">{{ hoveredCityTitle }}</div>
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div> <div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
</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, strength: 0,
intel: 0, intel: 0,
character: 'Random', character: 'Random',
pic: true, pic: false,
iconId: undefined, iconId: undefined,
inheritBonusStat: [0, 0, 0], inheritBonusStat: [0, 0, 0],
}); });
@@ -437,6 +437,7 @@ const loadConfig = async () => {
} else { } else {
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위'; 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.iconId = config.user.icons.find((icon) => icon.picture === config.user.preferredPicture)?.id;
form.value.pic = form.value.iconId !== undefined;
applyBalancedStats(); applyBalancedStats();
} }
} catch (err) { } catch (err) {
+12 -1
View File
@@ -33,6 +33,7 @@ type SettingForm = {
use_treatment: number; use_treatment: number;
use_auto_nation_turn: number; use_auto_nation_turn: number;
use_auto_nation_diplomacy: number; use_auto_nation_diplomacy: number;
use_auto_nation_war: number;
use_auto_nation_promotion: number; use_auto_nation_promotion: number;
use_auto_nation_finance: number; use_auto_nation_finance: number;
use_auto_nation_capital: number; use_auto_nation_capital: number;
@@ -69,6 +70,7 @@ const form = reactive<SettingForm>({
use_treatment: 10, use_treatment: 10,
use_auto_nation_turn: 1, use_auto_nation_turn: 1,
use_auto_nation_diplomacy: 0, use_auto_nation_diplomacy: 0,
use_auto_nation_war: 0,
use_auto_nation_promotion: 0, use_auto_nation_promotion: 0,
use_auto_nation_finance: 0, use_auto_nation_finance: 0,
use_auto_nation_capital: 0, use_auto_nation_capital: 0,
@@ -432,7 +434,16 @@ onMounted(() => {
:true-value="1" :true-value="1"
:false-value="0" :false-value="0"
/> />
자동 외교 (불가침 제의·선전포고) 자동 외교 (불가침 제의)
</label>
<label>
<input
v-model="form.use_auto_nation_war"
type="checkbox"
:true-value="1"
:false-value="0"
/>
자동 선전포고
</label> </label>
<label> <label>
<input <input
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil'; import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import PermissionMultiSelect from '../components/personnel/PermissionMultiSelect.vue';
import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue'; import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon'; import { resolveGeneralIconBackgroundImage } from '../utils/generalIcon';
@@ -99,7 +100,9 @@ const cityCandidates = (level: OfficerLevel): GeneralEntry[] => {
return candidates; return candidates;
}; };
const kickCandidates = computed(() => 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 => const awardText = (entries: PersonnelResponse['awards']['tigers']): string =>
entries.map((entry) => `${entry.name}${entry.value.toLocaleString('ko-KR')}`).join(', '); 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); else await appointCityOfficer(context.level, context.cityId, id);
}; };
const enforcePermissionLimit = (selection: number[]) => { const reportPermissionLimit = () => {
if (selection.length <= 2) return;
selection.splice(0, selection.length - 2);
showErrorToast('최대 2명까지 설정 가능합니다.'); showErrorToast('최대 2명까지 설정 가능합니다.');
}; };
@@ -388,23 +389,16 @@ onMounted(() => void loadPersonnel());
<tr> <tr>
<td class="green-cell permission-label">외교권자</td> <td class="green-cell permission-label">외교권자</td>
<td> <td>
<select <PermissionMultiSelect
v-model="ambassadorSelection" v-model="ambassadorSelection"
multiple label="외교권자"
aria-label="외교권자" :candidates="data.permissionCandidates.ambassadors"
@change="enforcePermissionLimit(ambassadorSelection)" @limit="reportPermissionLimit"
> />
<option
v-for="candidate in data.permissionCandidates.ambassadors"
:key="candidate.id"
:value="candidate.id"
>
{{ candidate.name }}
</option>
</select>
<button <button
class="legacy-button legacy-button--primary" class="legacy-button legacy-button--primary"
type="button" type="button"
aria-label="외교권자 임명 반영"
@click="changePermissions(true)" @click="changePermissions(true)"
> >
임명 임명
@@ -412,23 +406,16 @@ onMounted(() => void loadPersonnel());
</td> </td>
<td class="green-cell permission-label">조언자</td> <td class="green-cell permission-label">조언자</td>
<td> <td>
<select <PermissionMultiSelect
v-model="auditorSelection" v-model="auditorSelection"
multiple label="조언자"
aria-label="조언자" :candidates="data.permissionCandidates.auditors"
@change="enforcePermissionLimit(auditorSelection)" @limit="reportPermissionLimit"
> />
<option
v-for="candidate in data.permissionCandidates.auditors"
:key="candidate.id"
:value="candidate.id"
>
{{ candidate.name }}
</option>
</select>
<button <button
class="legacy-button legacy-button--primary" class="legacy-button legacy-button--primary"
type="button" type="button"
aria-label="조언자 임명 반영"
@click="changePermissions(false)" @click="changePermissions(false)"
> >
임명 임명
@@ -664,10 +651,6 @@ select {
background: #000; background: #000;
font: inherit; font: inherit;
} }
select[multiple] {
width: 300px;
height: 34px;
}
.nation-heading { .nation-heading {
height: 32px; height: 32px;
text-align: center; text-align: center;
+43 -13
View File
@@ -46,6 +46,7 @@ const generalPriority = ref<PriorityListState | null>(null);
const lastSavedNationPriority = ref<string[]>([]); const lastSavedNationPriority = ref<string[]>([]);
const lastSavedGeneralPriority = ref<string[]>([]); const lastSavedGeneralPriority = ref<string[]>([]);
const { success: showSuccessToast, error: showErrorToast, info: showInfoToast } = useGameFeedback(); const { success: showSuccessToast, error: showErrorToast, info: showInfoToast } = useGameFeedback();
const canManagePolicy = computed(() => (data.value?.permissionLevel ?? -1) >= 3);
const resolveErrorMessage = (value: unknown): string => { const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) return value.message; if (value instanceof Error) return value.message;
@@ -282,19 +283,19 @@ const priorityPanels = computed<PriorityPanel[]>(() => {
}); });
const resetPolicy = () => { const resetPolicy = () => {
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return; if (!canManagePolicy.value || !data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
policyDraft.value = clonePolicy(data.value.defaultNationPolicy); policyDraft.value = clonePolicy(data.value.defaultNationPolicy);
showInfoToast('서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.'); showInfoToast('서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.');
}; };
const rollbackPolicy = () => { const rollbackPolicy = () => {
if (!lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return; if (!canManagePolicy.value || !lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
policyDraft.value = clonePolicy(lastSavedPolicy.value); policyDraft.value = clonePolicy(lastSavedPolicy.value);
showInfoToast('이전 설정으로 되돌렸습니다.'); showInfoToast('이전 설정으로 되돌렸습니다.');
}; };
const submitPolicy = async () => { const submitPolicy = async () => {
if (!policyDraft.value || !window.confirm('저장할까요?')) return; if (!canManagePolicy.value || !policyDraft.value || !window.confirm('저장할까요?')) return;
try { try {
await trpc.npc.setNationPolicy.mutate(policyDraft.value); await trpc.npc.setNationPolicy.mutate(policyDraft.value);
lastSavedPolicy.value = clonePolicy(policyDraft.value); lastSavedPolicy.value = clonePolicy(policyDraft.value);
@@ -305,7 +306,7 @@ const submitPolicy = async () => {
}; };
const resetPriority = (section: PrioritySectionKey) => { const resetPriority = (section: PrioritySectionKey) => {
if (!data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return; if (!canManagePolicy.value || !data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
if (section === 'nation') { if (section === 'nation') {
nationPriority.value = assignPriorityState( nationPriority.value = assignPriorityState(
data.value.defaultNationPriority, data.value.defaultNationPriority,
@@ -321,7 +322,7 @@ const resetPriority = (section: PrioritySectionKey) => {
}; };
const rollbackPriority = (section: PrioritySectionKey) => { const rollbackPriority = (section: PrioritySectionKey) => {
if (!data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return; if (!canManagePolicy.value || !data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
if (section === 'nation') { if (section === 'nation') {
nationPriority.value = assignPriorityState( nationPriority.value = assignPriorityState(
lastSavedNationPriority.value, lastSavedNationPriority.value,
@@ -338,7 +339,7 @@ const rollbackPriority = (section: PrioritySectionKey) => {
const submitPriority = async (section: PrioritySectionKey) => { const submitPriority = async (section: PrioritySectionKey) => {
const state = section === 'nation' ? nationPriority.value : generalPriority.value; const state = section === 'nation' ? nationPriority.value : generalPriority.value;
if (!state || !window.confirm('저장할까요?')) return; if (!canManagePolicy.value || !state || !window.confirm('저장할까요?')) return;
try { try {
if (section === 'nation') { if (section === 'nation') {
await trpc.npc.setNationPriority.mutate(state.active); await trpc.npc.setNationPriority.mutate(state.active);
@@ -416,10 +417,16 @@ const submitPriority = async (section: PrioritySectionKey) => {
<div class="control_bar"> <div class="control_bar">
<div class="button-group"> <div class="button-group">
<button class="reset_btn" type="button" @click="resetPolicy">초깃값으로</button> <button class="reset_btn" type="button" :disabled="!canManagePolicy" @click="resetPolicy">
<button class="revert_btn" type="button" @click="rollbackPolicy">이전값으로</button> 초깃값으로
</button>
<button class="revert_btn" type="button" :disabled="!canManagePolicy" @click="rollbackPolicy">
이전값으로
</button>
</div> </div>
<button class="submit_btn" type="button" @click="submitPolicy">설정</button> <button class="submit_btn" type="button" :disabled="!canManagePolicy" @click="submitPolicy">
설정
</button>
</div> </div>
<div class="priority-sections"> <div class="priority-sections">
@@ -498,14 +505,31 @@ const submitPriority = async (section: PrioritySectionKey) => {
</div> </div>
<div class="control_bar priority-control"> <div class="control_bar priority-control">
<div class="button-group"> <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>
<button class="revert_btn" type="button" @click="rollbackPriority(panel.key)"> <button
class="revert_btn"
type="button"
:disabled="!canManagePolicy"
@click="rollbackPriority(panel.key)"
>
이전값으로 이전값으로
</button> </button>
</div> </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> </div>
</section> </section>
</div> </div>
@@ -728,10 +752,16 @@ const submitPriority = async (section: PrioritySectionKey) => {
border-radius: 4px; border-radius: 4px;
} }
.control_bar button:hover { .control_bar button:not(:disabled):hover {
filter: brightness(1.15); filter: brightness(1.15);
} }
.control_bar button:disabled {
cursor: not-allowed;
opacity: 0.55;
filter: none;
}
.control_bar button:focus-visible, .control_bar button:focus-visible,
.help-button:focus-visible { .help-button:focus-visible {
outline: 2px solid #fff; outline: 2px solid #fff;
@@ -429,7 +429,7 @@ onBeforeUnmount(() => {
<th class="legacy-bg1">전콘 선택</th> <th class="legacy-bg1">전콘 선택</th>
<td class="pool-icon-choice"> <td class="pool-icon-choice">
<label> <label>
<input v-model="selectedIconId" type="radio" value="" /> 선택한 장수 <input v-model="selectedIconId" type="radio" value="" /> 기본 아이
</label> </label>
<label v-for="icon in config.user.icons" :key="icon.id"> <label v-for="icon in config.user.icons" :key="icon.id">
<input v-model="selectedIconId" type="radio" :value="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 = { type FixtureOptions = {
failHweAdjustOnce?: boolean; failHweAdjustOnce?: boolean;
delayHweAdjust?: boolean; delayHweAdjust?: boolean;
@@ -65,6 +75,7 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
let preferredIconCount = 0; let preferredIconCount = 0;
let retireIconCount = 0; let retireIconCount = 0;
let hweAdjustCount = 0; let hweAdjustCount = 0;
const adjustIconInputs: Array<Record<string, unknown>> = [];
const operations = new Map<string, string[]>([ const operations = new Map<string, string[]>([
['che:903', []], ['che:903', []],
['hwe:903', []], ['hwe:903', []],
@@ -199,7 +210,8 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
`/${profileName.split(':')[0]}/api/trpc/${operationNames(route).join(',')}` `/${profileName.split(':')[0]}/api/trpc/${operationNames(route).join(',')}`
); );
const results = []; 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') { if (operation === 'auth.exchangeGatewayToken') {
operations.get(profileName)?.push('exchangeGatewayToken'); operations.get(profileName)?.push('exchangeGatewayToken');
results.push( results.push(
@@ -213,6 +225,11 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
} }
if (operation === 'general.adjustIcon') { if (operation === 'general.adjustIcon') {
operations.get(profileName)?.push('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') { if (profileName === 'hwe:903') {
hweAdjustCount += 1; hweAdjustCount += 1;
if (options.delayHweAdjust) { if (options.delayHweAdjust) {
@@ -242,6 +259,7 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
deleteIconCount: () => deleteIconCount, deleteIconCount: () => deleteIconCount,
preferredIconCount: () => preferredIconCount, preferredIconCount: () => preferredIconCount,
retireIconCount: () => retireIconCount, retireIconCount: () => retireIconCount,
adjustIconInputs: () => adjustIconInputs,
}; };
}; };
@@ -558,6 +576,9 @@ test('uses the Ref delete confirmation and opens the modal only after acceptance
.toBe('none'); .toBe('none');
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await expect(page.getByTestId('icon-server-modal')).toBeVisible(); 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 }) => { 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, gatewayToken: issued.gameToken,
}); });
const gameTrpc = createGameTrpc(row.profile, row.apiPort, exchanged.accessToken); 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'; row.state = 'success';
} catch (error) { } catch (error) {
row.state = 'error'; row.state = 'error';
+36 -8
View File
@@ -53,19 +53,47 @@ recovery dump.
### Gateway ### Gateway
| Legacy table | Target | Policy | | Legacy table | Target | Policy |
| --------------- | ----------------------------- | ------------------------------------------------------------------------------- | | --------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- |
| `member` | `app_user` plus `legacy_data` | Preserve identity, roles/ACL, sanctions, OAuth metadata, password hash and salt | | `member` | `app_user`, `user_icon`, `legacy_data` | Preserve identity, account icon, roles/ACL, sanctions, OAuth metadata, password hash and salt |
| `member_log` | `legacy_member_log` | Preserve complete JSON action history | | `member_log` | `legacy_member_log` | Preserve complete JSON action history |
| `banned_member` | `legacy_banned_member` | Preserve hashed-email ban | | `banned_member` | `legacy_banned_member` | Preserve hashed-email ban |
| `storage` | `legacy_root_key_value` | Preserve raw namespace/key/JSON value | | `storage` | `legacy_root_key_value` | Preserve raw namespace/key/JSON value |
| `system` | `system` | Preserve registration/login switches and notice | | `system` | `system` | Preserve registration/login switches and notice |
| `login_token` | none | Exclude expired bearer tokens, IP addresses and obsolete PHP sessions | | `login_token` | none | Exclude expired bearer tokens, IP addresses and obsolete PHP sessions |
Legacy member numbers map to deterministic UUIDs. Existing rows are updated by Legacy member numbers map to deterministic UUIDs. Existing rows are updated by
that UUID, so references such as `ng_old_generals.owner` remain stable even that UUID, so references such as `ng_old_generals.owner` remain stable even
when an old account was deleted before the dump. when an old account was deleted before the dump.
Ref appends `?=YYYYMMDD` to a custom icon filename as an HTTP cache marker; it
is not part of the stored filename. A Gateway plan with `userIcons` validates
every referenced byte before any upload. It reads legacy `d_pic` files without
following symlinks, checks the Ref 50 KiB/64~128px square/format contract, and
uploads the original bytes through sam-image's signed immutable upload API.
The deterministic per-account object name makes an interrupted apply safe to
repeat without putting user data in the image Git repository. Existing
`users/core/...` upload paths are fetched and validated instead of copied.
Only after every source icon validates and every legacy file upload succeeds
does the PostgreSQL transaction begin. The returned `icons/users/core2026/...`
path is checked exactly, stored as `users/core2026/...` with `image_server=0`,
and connected to an owned `user_icon` row. An unchanged Ref selection is moved
to that path. A newer Core selection is not overwritten; its imported Ref icon
is retained as another library entry. If the Core account is currently on the
default icon, the imported Ref entry is recorded retired so a prior selection
is not silently resurrected. The original Ref path, `IMGSVR`, returned path and
byte SHA-256 remain in `legacy_data`. Picture collisions across owners fail the
transaction.
Historical bytes that violate the Ref validation contract block preflight.
Operators may list a reviewed member in `excludedMemberNumbers`; the importer
then proves the file is still invalid and records the reason. It never uploads
that byte or creates a `user_icon` row. If the target still selects the rejected
Ref path it moves only that selection to `default.jpg`; a newer Core selection
is preserved. A stale exclusion whose file has become valid also blocks the
plan, so this cannot become a general skip-errors switch.
Kakao members retain `oauth_id`, email and metadata. A non-empty provider ID is Kakao members retain `oauth_id`, email and metadata. A non-empty provider ID is
required before an imported row is marked Kakao-verified. A parseable legacy required before an imported row is marked Kakao-verified. A parseable legacy
`token_valid_until` is copied to `kakao_talk_verified_until`, preserving the `token_valid_until` is copied to `kakao_talk_verified_until`, preserving the
+1
View File
@@ -164,6 +164,7 @@ export type TurnDaemonCommand =
use_treatment?: number; use_treatment?: number;
use_auto_nation_turn?: number; use_auto_nation_turn?: number;
use_auto_nation_diplomacy?: number; use_auto_nation_diplomacy?: number;
use_auto_nation_war?: number;
use_auto_nation_promotion?: number; use_auto_nation_promotion?: number;
use_auto_nation_finance?: number; use_auto_nation_finance?: number;
use_auto_nation_capital?: number; use_auto_nation_capital?: number;
+3
View File
@@ -566,6 +566,9 @@ importers:
pg: pg:
specifier: ^8.16.3 specifier: ^8.16.3
version: 8.23.0 version: 8.23.0
sharp:
specifier: ^0.35.0
version: 0.35.3(@types/node@26.2.0)
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^26.2.0 specifier: ^26.2.0
@@ -91,7 +91,7 @@ const ownTarget = target(1, '테스트장수', 1, '테스트국', '#d32f2f');
const foreignTarget = target(8, '상대장수', 2, '상대국', '#2457a6'); const foreignTarget = target(8, '상대장수', 2, '상대국', '#2457a6');
const messageTime = new Date().toISOString().replace('T', ' ').slice(0, 19); const messageTime = new Date().toISOString().replace('T', ' ').slice(0, 19);
const buildMessages = (permission: number) => ({ const buildMessages = (permission: number, tombstonedMessageIds: ReadonlySet<number> = new Set()) => ({
result: true, result: true,
public: [ public: [
{ {
@@ -99,8 +99,8 @@ const buildMessages = (permission: number) => ({
msgType: 'public', msgType: 'public',
src: ownTarget, src: ownTarget,
dest: null, dest: null,
text: '전체 메시지 본문', text: tombstonedMessageIds.has(101) ? '삭제된 메시지입니다.' : '전체 메시지 본문',
option: {}, option: tombstonedMessageIds.has(101) ? { invalid: true } : {},
time: messageTime, time: messageTime,
}, },
], ],
@@ -150,11 +150,11 @@ const buildMessages = (permission: number) => ({
msgType: 'diplomacy', msgType: 'diplomacy',
src: foreignTarget, src: foreignTarget,
dest: target(0, '', 1, '테스트국', '#d32f2f'), dest: target(0, '', 1, '테스트국', '#d32f2f'),
text: permission >= 3 ? '외교 메시지 본문' : '(외교 메시지입니다)', text: permission >= 3 ? '외교 메시지 본문' : '조회 권한이 없는 외교 메시지입니다.',
option: option:
permission >= 3 permission >= 3
? { action: 'noAggression', deletable: false } ? { action: 'noAggression', deletable: false }
: { action: 'noAggression', deletable: false, invalid: true }, : { action: 'noAggression', deletable: false },
time: messageTime, time: messageTime,
}, },
], ],
@@ -203,6 +203,7 @@ const installFixture = async (
options: { permission: number; sendError?: string } options: { permission: number; sendError?: string }
): Promise<Array<{ operation: string; body: unknown }>> => { ): Promise<Array<{ operation: string; body: unknown }>> => {
const mutations: Array<{ operation: string; body: unknown }> = []; const mutations: Array<{ operation: string; body: unknown }> = [];
const tombstonedMessageIds = new Set<number>();
await page.addInitScript( await page.addInitScript(
({ gameToken, profile }) => { ({ gameToken, profile }) => {
window.localStorage.setItem('sammo-game-token', gameToken); window.localStorage.setItem('sammo-game-token', gameToken);
@@ -272,7 +273,9 @@ const installFixture = async (
if (operation === 'general.getRecentRecords') { if (operation === 'general.getRecentRecords') {
return response({ global: [], general: [], history: [] }); return response({ global: [], general: [], history: [] });
} }
if (operation === 'messages.getRecent') return response(buildMessages(options.permission)); if (operation === 'messages.getRecent') {
return response(buildMessages(options.permission, tombstonedMessageIds));
}
if (operation === 'messages.getContacts') return response(contacts); if (operation === 'messages.getContacts') return response(contacts);
if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true }); if (operation === 'board.getAccess') return response({ canMeeting: true, canSecret: true });
if (operation === 'tournament.getState') return response({ stage: 0 }); if (operation === 'tournament.getState') return response({ stage: 0 });
@@ -287,6 +290,10 @@ const installFixture = async (
if (operation === 'messages.send' && options.sendError) { if (operation === 'messages.send' && options.sendError) {
return errorResponse(operation, options.sendError); return errorResponse(operation, options.sendError);
} }
if (operation === 'messages.delete') {
tombstonedMessageIds.add(101);
return response({ ok: true, deletedIds: [101] });
}
return response(operation === 'messages.respond' ? { result: true, reason: 'success' } : { ok: true }); return response(operation === 'messages.respond' ? { result: true, reason: 'success' } : { ok: true });
} }
return errorResponse(operation, `Unhandled message fixture operation: ${operation}`); return errorResponse(operation, `Unhandled message fixture operation: ${operation}`);
@@ -437,6 +444,16 @@ test('exposes nation targets including wanderers, reply, read, delete, and succe
page.once('dialog', (dialog) => dialog.accept()); page.once('dialog', (dialog) => dialog.accept());
await deleteButton.click(); await deleteButton.click();
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1);
await expect(page.locator('.PublicTalk .msg-plate').filter({ hasText: '삭제된 메시지입니다' })).toBeVisible();
await expect(page.locator('.PublicTalk')).not.toContainText('전체 메시지 본문');
await expect(page.locator('.PublicTalk .delete-message')).toHaveCount(0);
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await page.locator('.PublicTalk').screenshot({
path: resolve(artifactRoot, 'message-delete-tombstone-500.png'),
animations: 'disabled',
});
}
await select.selectOption('9000'); await select.selectOption('9000');
await page.getByLabel('메시지 입력').fill('우리 나라로 와주세요'); await page.getByLabel('메시지 입력').fill('우리 나라로 와주세요');
@@ -496,9 +513,17 @@ test('redacts diplomacy for a low-permission general and preserves the failed-se
const select = page.getByLabel('메시지 수신 대상'); const select = page.getByLabel('메시지 수신 대상');
await expect(select.locator('option[value="9000"]')).toHaveCount(0); await expect(select.locator('option[value="9000"]')).toHaveCount(0);
await expect(select.locator('option[value="9002"]')).toHaveCount(0); await expect(select.locator('option[value="9002"]')).toHaveCount(0);
await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다'); await expect(page.locator('.DiplomacyTalk')).toContainText('조회 권한이 없는 외교 메시지입니다.');
await expect(page.locator('.DiplomacyTalk')).not.toContainText('삭제된 메시지입니다');
await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문'); await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문');
await expect(page.locator('.DiplomacyTalk .message-response button').first()).toBeDisabled(); await expect(page.locator('.DiplomacyTalk .message-response button').first()).toBeDisabled();
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await page.locator('.DiplomacyTalk').screenshot({
path: resolve(artifactRoot, 'diplomacy-permission-redaction-500.png'),
animations: 'disabled',
});
}
await select.selectOption('9999'); await select.selectOption('9999');
await page.getByLabel('메시지 입력').fill('차단될 메시지'); await page.getByLabel('메시지 입력').fill('차단될 메시지');
+18 -2
View File
@@ -39,6 +39,18 @@ Gateway and each game profile. A password can come from a separate mode-0600
file (recommended), an environment variable, or directly from the mode-0600 file (recommended), an environment variable, or directly from the mode-0600
plan. Target PostgreSQL URLs remain in the named environment variables. plan. Target PostgreSQL URLs remain in the named environment variables.
When the Gateway source contains a non-default member icon, `gateway.userIcons`
is mandatory. Mount Ref's `d_pic` directory read-only as `sourceDirectory`, and
mount the Core2026 sam-image upload secret as a mode-0600 `uploadSecretFile`.
The two URL fields normally point to `https://sam-image.hided.net` and its
`/icons` path. The importer never adds account images to the image Git tree.
An invalid historical file blocks the plan by default. After byte-level review,
its member number may be listed in `excludedMemberNumbers`; the exclusion is
accepted only while that exact member still has invalid image geometry/format.
A valid file or stale/missing member exclusion fails closed. An unchanged
invalid Ref selection is reset to the default icon instead of publishing bad
bytes; a newer Core selection is preserved.
```sh ```sh
mkdir -p tools/legacy-db-migration/secrets mkdir -p tools/legacy-db-migration/secrets
chmod 700 tools/legacy-db-migration/secrets chmod 700 tools/legacy-db-migration/secrets
@@ -69,7 +81,9 @@ host alias; do not put credentials in the plan.
`check-plan` opens every source and target without writing. Its stage JSON lists `check-plan` opens every source and target without writing. Its stage JSON lists
every included item as `inventory`, including source, target, strategy and the every included item as `inventory`, including source, target, strategy and the
information transferred. A configured battle-result source also reports its information transferred. Gateway preflight validates every local or already
uploaded custom icon and reports the source split without issuing a PUT. A
configured battle-result source also reports its
season/file/byte counts. `run-plan` also season/file/byte counts. `run-plan` also
preflights every stage before the first import, is a dry-run without `--apply`, preflights every stage before the first import, is a dry-run without `--apply`,
and stops at the first failed stage. Completed earlier stages remain committed; and stops at the first failed stage. Completed earlier stages remain committed;
@@ -143,7 +157,9 @@ database.
The individual commands also accept `--mode incremental` and `--source-key`. The individual commands also accept `--mode incremental` and `--source-key`.
Use the ordered plan for production so every configured connection is checked Use the ordered plan for production so every configured connection is checked
before the Gateway stage starts. before the Gateway stage starts. The direct `gateway` command intentionally
fails closed when its source contains custom icons because it has no secure
structured icon-upload configuration; use `run-plan` for that source.
### Isolated current-season comparison fixture ### Isolated current-season comparison fixture
@@ -10,6 +10,13 @@
"passwordFile": "./secrets/mysql-root-password", "passwordFile": "./secrets/mysql-root-password",
"tls": true "tls": true
}, },
"userIcons": {
"sourceDirectory": "/run/sammo-migration/user-icons",
"uploadBaseUrl": "https://sam-image.hided.net",
"publicBaseUrl": "https://sam-image.hided.net/icons",
"uploadSecretFile": "/run/secrets/image_upload_core2026_secret",
"excludedMemberNumbers": []
},
"targetUrlEnv": "GATEWAY_DATABASE_URL" "targetUrlEnv": "GATEWAY_DATABASE_URL"
}, },
"profiles": [ "profiles": [
+2 -1
View File
@@ -17,7 +17,8 @@
"dependencies": { "dependencies": {
"@sammo-ts/common": "workspace:*", "@sammo-ts/common": "workspace:*",
"mariadb": "3.5.3", "mariadb": "3.5.3",
"pg": "^8.16.3" "pg": "^8.16.3",
"sharp": "^0.35.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
+62 -1
View File
@@ -6,6 +6,7 @@ import path from 'node:path';
import { resolveBattleResultSourceConfig, type BattleResultSourceConfig } from './battleResultSource.js'; import { resolveBattleResultSourceConfig, type BattleResultSourceConfig } from './battleResultSource.js';
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from './game.js'; import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from './game.js';
import { fingerprintMariaConnection, type MigrationSourceIdentity } from './incremental.js'; import { fingerprintMariaConnection, type MigrationSourceIdentity } from './incremental.js';
import type { LegacyUserIconTransferConfig } from './legacyUserIcons.js';
export interface ResolvedMigrationStage { export interface ResolvedMigrationStage {
kind: 'gateway' | 'game'; kind: 'gateway' | 'game';
@@ -15,6 +16,7 @@ export interface ResolvedMigrationStage {
targetUrl: string; targetUrl: string;
sourceIdentity: MigrationSourceIdentity; sourceIdentity: MigrationSourceIdentity;
battleResults?: BattleResultSourceConfig; battleResults?: BattleResultSourceConfig;
userIcons?: LegacyUserIconTransferConfig;
} }
export interface ResolvedMigrationPlan { export interface ResolvedMigrationPlan {
@@ -139,11 +141,65 @@ const resolveTargetUrl = (record: Record<string, unknown>, label: string): strin
const parseStage = (value: unknown, label: string): Record<string, unknown> => { const parseStage = (value: unknown, label: string): Record<string, unknown> => {
const record = asRecord(value, label); const record = asRecord(value, label);
rejectUnknownKeys(record, ['source', 'targetUrlEnv', 'profile', 'enabled', 'battleResults'], label); rejectUnknownKeys(record, ['source', 'targetUrlEnv', 'profile', 'enabled', 'battleResults', 'userIcons'], label);
if (!('source' in record)) throw new Error(`${label}.source is required`); if (!('source' in record)) throw new Error(`${label}.source is required`);
return record; return record;
}; };
const resolveWebBaseUrl = (value: string, label: string): string => {
let url: URL;
try {
url = new URL(value);
} catch (error) {
throw new Error(`${label} must be an absolute URL`, { cause: error });
}
const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1';
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
throw new Error(`${label} must use HTTPS except for a loopback test service`);
}
if (url.username || url.password || url.search || url.hash) {
throw new Error(`${label} must not contain credentials, a query, or a fragment`);
}
return url.toString().replace(/\/$/u, '');
};
const resolveUserIcons = async (
value: unknown,
configDirectory: string,
label: string
): Promise<LegacyUserIconTransferConfig> => {
const record = asRecord(value, label);
rejectUnknownKeys(
record,
['sourceDirectory', 'uploadBaseUrl', 'publicBaseUrl', 'uploadSecretFile', 'excludedMemberNumbers'],
label
);
const sourceDirectory = path.resolve(configDirectory, requiredString(record, 'sourceDirectory', label));
const sourceInfo = await lstat(sourceDirectory);
if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
throw new Error(`${label}.sourceDirectory must be a directory and not a symbolic link`);
}
const uploadBaseUrl = resolveWebBaseUrl(requiredString(record, 'uploadBaseUrl', label), `${label}.uploadBaseUrl`);
const publicBaseUrl = resolveWebBaseUrl(requiredString(record, 'publicBaseUrl', label), `${label}.publicBaseUrl`);
const secretPath = path.resolve(configDirectory, requiredString(record, 'uploadSecretFile', label));
const uploadSecret = (await readSecureText(secretPath, `${label}.uploadSecretFile`)).replace(/\r?\n$/u, '');
if (uploadSecret.length < 32) throw new Error(`${label}.uploadSecretFile must contain at least 32 characters`);
const excludedMemberNumbers = record.excludedMemberNumbers ?? [];
if (
!Array.isArray(excludedMemberNumbers) ||
excludedMemberNumbers.some((value) => !Number.isSafeInteger(value) || Number(value) <= 0)
) {
throw new Error(`${label}.excludedMemberNumbers must contain only positive safe integers`);
}
return {
sourceDirectory,
uploadBaseUrl,
publicBaseUrl,
uploadSecret,
excludedMemberNumbers: excludedMemberNumbers as number[],
};
};
export const loadMigrationPlan = async (configPathInput: string): Promise<ResolvedMigrationPlan> => { export const loadMigrationPlan = async (configPathInput: string): Promise<ResolvedMigrationPlan> => {
const configPath = path.resolve(configPathInput); const configPath = path.resolve(configPathInput);
const rawText = await readSecureText(configPath, 'Migration config'); const rawText = await readSecureText(configPath, 'Migration config');
@@ -164,6 +220,10 @@ export const loadMigrationPlan = async (configPathInput: string): Promise<Resolv
if (root.gateway !== undefined) { if (root.gateway !== undefined) {
const gateway = parseStage(root.gateway, 'gateway'); const gateway = parseStage(root.gateway, 'gateway');
const sourceUrl = await resolveSource(gateway.source, configDirectory, 'gateway.source'); const sourceUrl = await resolveSource(gateway.source, configDirectory, 'gateway.source');
const userIcons =
gateway.userIcons === undefined
? undefined
: await resolveUserIcons(gateway.userIcons, configDirectory, 'gateway.userIcons');
stages.push({ stages.push({
kind: 'gateway', kind: 'gateway',
name: 'gateway', name: 'gateway',
@@ -173,6 +233,7 @@ export const loadMigrationPlan = async (configPathInput: string): Promise<Resolv
key: `${sourceSet}:gateway`, key: `${sourceSet}:gateway`,
fingerprint: fingerprintMariaConnection(sourceUrl), fingerprint: fingerprintMariaConnection(sourceUrl),
}, },
...(userIcons ? { userIcons } : {}),
}); });
} }
+90 -12
View File
@@ -27,6 +27,16 @@ import {
type MigrationExecutionOptions, type MigrationExecutionOptions,
type MigrationProgress, type MigrationProgress,
} from './incremental.js'; } from './incremental.js';
import {
normalizeLegacyIconPicture,
prepareLegacyUserIcons,
syncImportedUserIcons,
syncRejectedUserIcons,
type LegacyUserIconPreparation,
type LegacyUserIconTransferConfig,
type PreparedLegacyUserIcon,
type RejectedLegacyUserIcon,
} from './legacyUserIcons.js';
import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js'; import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js';
export interface MigrationSummary { export interface MigrationSummary {
@@ -105,7 +115,15 @@ export const preflightMemberConflicts = async (target: PoolClient, rows: readonl
} }
}; };
export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => { export { normalizeLegacyIconPicture } from './legacyUserIcons.js';
export const mapMember = (
row: SourceRow,
migratedAt: Date,
lastLoginAt: Date | null,
importedIcon?: PreparedLegacyUserIcon,
rejectedIcon?: RejectedLegacyUserIcon
): TargetRow => {
const memberNo = toNumber(row.NO, 'member.NO'); const memberNo = toNumber(row.NO, 'member.NO');
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`); const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
const acl = parseJson(row.acl, `member.${memberNo}.acl`); const acl = parseJson(row.acl, `member.${memberNo}.acl`);
@@ -114,11 +132,17 @@ export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date |
const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE'; const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE';
const oauthId = toNullableString(row.oauth_id)?.trim() || null; const oauthId = toNullableString(row.oauth_id)?.trim() || null;
const passwordHash = toStringValue(row.PW, `member.${memberNo}.PW`); const passwordHash = toStringValue(row.PW, `member.${memberNo}.PW`);
const rawPicture = toNullableString(row.PICTURE) ?? 'default.jpg';
const imageServer = toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`);
const legacyData: JsonValue = { const legacyData: JsonValue = {
memberNo, memberNo,
grade, grade,
acl, acl,
penalty, penalty,
picture: rawPicture,
imageServer,
...(importedIcon ? { importedPicture: importedIcon.picture, importedPictureSha256: importedIcon.sha256 } : {}),
...(rejectedIcon ? { rejectedPictureReason: rejectedIcon.reason } : {}),
tokenValidUntil: toNullableString(row.token_valid_until), tokenValidUntil: toNullableString(row.token_valid_until),
regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`), regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`),
blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`), blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`),
@@ -137,8 +161,8 @@ export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date |
oauth_id: oauthId, oauth_id: oauthId,
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null, email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
oauth_info: jsonParameter(oauthInfo), oauth_info: jsonParameter(oauthInfo),
picture: toNullableString(row.PICTURE) ?? 'default.jpg', picture: importedIcon?.picture ?? (rejectedIcon ? 'default.jpg' : normalizeLegacyIconPicture(rawPicture)),
image_server: toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`), image_server: importedIcon?.imageServer ?? (rejectedIcon ? 0 : imageServer),
icon_updated_at: null, icon_updated_at: null,
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0, third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
terms_accepted_at: null, terms_accepted_at: null,
@@ -175,19 +199,53 @@ const processMembers = async (
target: PoolClient | null, target: PoolClient | null,
apply: boolean, apply: boolean,
migratedAt: Date, migratedAt: Date,
counts: Record<string, number> counts: Record<string, number>,
preparedIcons: ReadonlyMap<number, PreparedLegacyUserIcon>,
rejectedIcons: ReadonlyMap<number, RejectedLegacyUserIcon>
): Promise<void> => { ): Promise<void> => {
const lastLogins = await loadLastLogins(source); const lastLogins = await loadLastLogins(source);
for await (const rows of paginateSource(source, 'member', 'NO', batchSize)) { for await (const rows of paginateSource(source, 'member', 'NO', batchSize)) {
const mapped = rows.map((row) => { const mapped = rows.map((row) => {
const memberNo = toNumber(row.NO, 'member.NO'); const memberNo = toNumber(row.NO, 'member.NO');
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null); const importedIcon = preparedIcons.get(memberNo);
const rejectedIcon = rejectedIcons.get(memberNo);
const sourcePicture = toNullableString(row.PICTURE) ?? 'default.jpg';
if (
(sourcePicture !== 'default.jpg' && !importedIcon && !rejectedIcon) ||
(importedIcon && importedIcon.sourcePicture !== sourcePicture) ||
(rejectedIcon && rejectedIcon.sourcePicture !== sourcePicture)
) {
throw new Error(`member.${memberNo}.PICTURE changed after user-icon preflight`);
}
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null, importedIcon, rejectedIcon);
}); });
if (target) { if (target) {
await preflightMemberConflicts(target, mapped); await preflightMemberConflicts(target, mapped);
} }
if (target && apply) { if (target && apply) {
await upsertRows(target, 'app_user', mapped, ['id'], { preserveOnConflict: MEMBER_PRESERVED_COLUMNS }); await upsertRows(target, 'app_user', mapped, ['id'], { preserveOnConflict: MEMBER_PRESERVED_COLUMNS });
const synced = await syncImportedUserIcons(
target,
rows
.map((row) => preparedIcons.get(toNumber(row.NO, 'member.NO')))
.filter((icon): icon is PreparedLegacyUserIcon => Boolean(icon)),
migratedAt
);
counts.user_icon_current_linked = (counts.user_icon_current_linked ?? 0) + synced.currentLinked;
counts.user_icon_library_inserted = (counts.user_icon_library_inserted ?? 0) + synced.libraryInserted;
counts.user_icon_library_retired = (counts.user_icon_library_retired ?? 0) + synced.libraryRetired;
counts.user_icon_target_preserved = (counts.user_icon_target_preserved ?? 0) + synced.targetPreserved;
const rejected = await syncRejectedUserIcons(
target,
rows
.map((row) => rejectedIcons.get(toNumber(row.NO, 'member.NO')))
.filter((icon): icon is RejectedLegacyUserIcon => Boolean(icon)),
migratedAt
);
counts.user_icon_rejected_current_reset =
(counts.user_icon_rejected_current_reset ?? 0) + rejected.currentReset;
counts.user_icon_rejected_target_preserved =
(counts.user_icon_rejected_target_preserved ?? 0) + rejected.targetPreserved;
} }
counts.member = (counts.member ?? 0) + mapped.length; counts.member = (counts.member ?? 0) + mapped.length;
} }
@@ -285,7 +343,8 @@ export const migrateGateway = async (
targetPool: PgPool | null, targetPool: PgPool | null,
apply: boolean, apply: boolean,
migratedAt: Date, migratedAt: Date,
execution: MigrationExecutionOptions = defaultExecutionOptions('legacy-root') execution: MigrationExecutionOptions = defaultExecutionOptions('legacy-root'),
userIconConfig?: LegacyUserIconTransferConfig
): Promise<MigrationSummary> => { ): Promise<MigrationSummary> => {
validateSourceIdentity(execution.source); validateSourceIdentity(execution.source);
if (execution.mode === 'incremental' && !targetPool) { if (execution.mode === 'incremental' && !targetPool) {
@@ -300,8 +359,21 @@ export const migrateGateway = async (
const client = targetPool ? await targetPool.connect() : null; const client = targetPool ? await targetPool.connect() : null;
let importRunId: string | null = null; let importRunId: string | null = null;
try { try {
const run = async (runId: string | null): Promise<void> => { const sourceIconRows = await querySource(
await processMembers(source, client, apply, migratedAt, counts); source,
`SELECT NO, PICTURE, IMGSVR, REG_DATE
FROM member WHERE PICTURE <> 'default.jpg' ORDER BY NO`
);
const recordIconCounts = (prepared: LegacyUserIconPreparation): void => {
counts.user_icon_source = prepared.counts.custom;
counts.user_icon_legacy_file = prepared.counts.legacyFiles;
counts.user_icon_existing_upload = prepared.counts.existingUploads;
counts.user_icon_uploaded = prepared.counts.uploaded;
counts.user_icon_rejected = prepared.counts.rejected;
};
const run = async (runId: string | null, prepared: LegacyUserIconPreparation): Promise<void> => {
recordIconCounts(prepared);
await processMembers(source, client, apply, migratedAt, counts, prepared.icons, prepared.rejected);
progress.member = { progress.member = {
strategy: 'rescan', strategy: 'rescan',
startAfterId: null, startAfterId: null,
@@ -365,9 +437,13 @@ export const migrateGateway = async (
); );
importRunId = created.rows[0]?.id ?? null; importRunId = created.rows[0]?.id ?? null;
if (!importRunId) throw new Error('Failed to create legacy gateway import run'); if (!importRunId) throw new Error('Failed to create legacy gateway import run');
await client.query('BEGIN'); let transactionStarted = false;
try { try {
await run(importRunId); const prepared = await prepareLegacyUserIcons(sourceIconRows, userIconConfig, true);
recordIconCounts(prepared);
await client.query('BEGIN');
transactionStarted = true;
await run(importRunId, prepared);
await client.query( await client.query(
`UPDATE "legacy_import_run" `UPDATE "legacy_import_run"
SET "status" = 'COMPLETED', "finished_at" = CURRENT_TIMESTAMP, SET "status" = 'COMPLETED', "finished_at" = CURRENT_TIMESTAMP,
@@ -376,8 +452,9 @@ export const migrateGateway = async (
[importRunId, JSON.stringify(counts), JSON.stringify(progress)] [importRunId, JSON.stringify(counts), JSON.stringify(progress)]
); );
await client.query('COMMIT'); await client.query('COMMIT');
transactionStarted = false;
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); if (transactionStarted) await client.query('ROLLBACK');
const message = const message =
error instanceof Error ? error.message.slice(0, 2000) : String(error).slice(0, 2000); error instanceof Error ? error.message.slice(0, 2000) : String(error).slice(0, 2000);
await client.query( await client.query(
@@ -391,7 +468,8 @@ export const migrateGateway = async (
} }
}); });
} else { } else {
await run(null); const prepared = await prepareLegacyUserIcons(sourceIconRows, userIconConfig, false);
await run(null, prepared);
} }
} finally { } finally {
client?.release(); client?.release();
+2 -2
View File
@@ -10,9 +10,9 @@ export interface MigrationInventoryItem {
export const GATEWAY_MIGRATION_INVENTORY: readonly MigrationInventoryItem[] = [ export const GATEWAY_MIGRATION_INVENTORY: readonly MigrationInventoryItem[] = [
{ {
source: 'member', source: 'member',
target: 'app_user + legacy_data', target: 'app_user + user_icon + legacy_data',
strategy: 'rescan', strategy: 'rescan',
contents: '계정 식별자, 레거시 역할/제재, OAuth 메타데이터와 비밀번호 복구 자료', contents: '계정 식별자, 전용 아이콘 목록, 레거시 역할/제재, OAuth 메타데이터와 비밀번호 복구 자료',
}, },
{ {
source: 'member_log', source: 'member_log',
@@ -0,0 +1,510 @@
import { constants } from 'node:fs';
import { lstat, open } from 'node:fs/promises';
import path from 'node:path';
import { createHash, createHmac } from 'node:crypto';
import sharp from 'sharp';
import type { PoolClient } from 'pg';
import { legacyUserId } from './identity.js';
import { toDate, toNumber, toStringValue, type SourceRow } from './db.js';
const MAX_ICON_BYTES = 50 * 1024;
const LEGACY_CACHE_SUFFIX = /\?=([0-9]{8})$/u;
const REMOTE_PICTURE = /^users\/(?:core|core2026)\/[a-f0-9]{32}\.(?:avif|webp|jpe?g|png|gif)$/u;
const LOCAL_PICTURE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,190}\.(?:avif|webp|jpe?g|png|gif)$/u;
const CONTENT_TYPES: Record<string, string> = {
avif: 'image/avif',
webp: 'image/webp',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
};
export interface LegacyUserIconTransferConfig {
sourceDirectory: string;
uploadBaseUrl: string;
publicBaseUrl: string;
uploadSecret: string;
excludedMemberNumbers: readonly number[];
}
export interface PreparedLegacyUserIcon {
memberNo: number;
userId: string;
sourcePicture: string;
normalizedSourcePicture: string;
sourceImageServer: number;
picture: string;
imageServer: 0;
createdAt: Date;
source: 'legacy-file' | 'existing-upload';
sha256: string;
}
export interface LegacyUserIconPreparation {
icons: Map<number, PreparedLegacyUserIcon>;
rejected: Map<number, RejectedLegacyUserIcon>;
counts: {
custom: number;
legacyFiles: number;
existingUploads: number;
uploaded: number;
rejected: number;
};
}
export interface RejectedLegacyUserIcon {
memberNo: number;
userId: string;
sourcePicture: string;
normalizedSourcePicture: string;
sourceImageServer: number;
reason: string;
}
export interface LegacyUserIconSyncCounts {
currentLinked: number;
libraryInserted: number;
libraryRetired: number;
targetPreserved: number;
}
export interface RejectedLegacyUserIconSyncCounts {
currentReset: number;
targetPreserved: number;
}
interface ValidatedImage {
body: Buffer;
extension: string;
contentType: string;
sha256: string;
}
class LegacyUserIconValidationError extends Error {}
const encodedPicturePath = (picture: string): string => picture.split('/').map(encodeURIComponent).join('/');
export const normalizeLegacyIconPicture = (picture: string): string => picture.replace(LEGACY_CACHE_SUFFIX, '');
const iconCreatedAt = (sourcePicture: string, fallback: Date): Date => {
const marker = sourcePicture.match(LEGACY_CACHE_SUFFIX)?.[1];
if (!marker) return fallback;
const year = Number(marker.slice(0, 4));
const month = Number(marker.slice(4, 6));
const day = Number(marker.slice(6, 8));
const parsed = new Date(Date.UTC(year, month - 1, day, -9));
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
};
const validateImage = async (body: Buffer, label: string): Promise<ValidatedImage> => {
if (body.length === 0 || body.length > MAX_ICON_BYTES) {
throw new LegacyUserIconValidationError(`${label} must be non-empty and at most 50 KiB`);
}
let metadata: {
mediaType?: string;
format?: string;
width?: number;
height?: number;
pageHeight?: number;
pages?: number;
};
try {
metadata = await sharp(body, { animated: true }).metadata();
} catch (error) {
throw new LegacyUserIconValidationError(`${label} is not a decodable image`, { cause: error });
}
const detected = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
const extension = detected === 'jpeg' ? 'jpg' : detected;
if (!extension || !CONTENT_TYPES[extension]) {
throw new LegacyUserIconValidationError(`${label} must be avif, webp, jpeg, png, or gif`);
}
const frameHeight = metadata.pages && metadata.pages > 1 ? metadata.pageHeight : metadata.height;
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || frameHeight !== metadata.width) {
throw new LegacyUserIconValidationError(`${label} must be a square image from 64x64 through 128x128`);
}
return {
body,
extension,
contentType: CONTENT_TYPES[extension]!,
sha256: createHash('sha256').update(body).digest('hex'),
};
};
const readLegacyIcon = async (directory: string, picture: string, memberNo: number): Promise<ValidatedImage> => {
if (!LOCAL_PICTURE.test(picture) || path.basename(picture) !== picture) {
throw new Error(`member.${memberNo}.PICTURE is not a safe Ref d_pic filename`);
}
const filePath = path.resolve(directory, picture);
if (path.dirname(filePath) !== path.resolve(directory)) {
throw new Error(`member.${memberNo}.PICTURE escapes the Ref d_pic directory`);
}
const info = await lstat(filePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`member.${memberNo}.PICTURE must resolve to a regular non-symlink file`);
}
if (info.size === 0 || info.size > MAX_ICON_BYTES) {
throw new LegacyUserIconValidationError(`member.${memberNo}.PICTURE must be non-empty and at most 50 KiB`);
}
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
return await validateImage(await handle.readFile(), `member.${memberNo}.PICTURE`);
} finally {
await handle.close();
}
};
const fetchExistingIcon = async (
config: LegacyUserIconTransferConfig,
picture: string,
memberNo: number,
fetchImpl: typeof fetch
): Promise<ValidatedImage> => {
if (!REMOTE_PICTURE.test(picture)) {
throw new Error(`member.${memberNo}.PICTURE is neither a Ref d_pic filename nor a sam-image upload path`);
}
const response = await fetchImpl(`${config.publicBaseUrl.replace(/\/$/u, '')}/${encodedPicturePath(picture)}`, {
headers: { accept: 'image/avif,image/webp,image/png,image/jpeg,image/gif' },
});
if (!response.ok) {
throw new Error(`member.${memberNo}.PICTURE is unavailable from sam-image (HTTP ${response.status})`);
}
const contentLength = Number(response.headers.get('content-length') ?? 0);
if (contentLength > MAX_ICON_BYTES) {
throw new Error(`member.${memberNo}.PICTURE exceeds 50 KiB on sam-image`);
}
const body = Buffer.from(await response.arrayBuffer());
return validateImage(body, `member.${memberNo}.PICTURE`);
};
const deterministicUploadName = (memberNo: number, sourcePicture: string, image: ValidatedImage): string => {
const stem = createHash('sha256')
.update('legacy-ref-user-icon-v1\0')
.update(String(memberNo))
.update('\0')
.update(sourcePicture)
.update('\0')
.update(image.sha256)
.digest('hex')
.slice(0, 32);
return `${stem}.${image.extension}`;
};
const uploadSignature = (
secret: string,
expires: string,
requestId: string,
pathname: string,
contentType: string,
body: Buffer
): string => {
const digest = createHash('sha256').update(body).digest('hex');
return createHmac('sha256', secret)
.update(`${expires}.${requestId}.${pathname}.${contentType}.${digest}`)
.digest('hex');
};
const uploadLegacyIcon = async (
config: LegacyUserIconTransferConfig,
memberNo: number,
sourcePicture: string,
image: ValidatedImage,
fetchImpl: typeof fetch,
now: () => number
): Promise<string> => {
const filename = deterministicUploadName(memberNo, sourcePicture, image);
const pathname = `/v1/uploads/user-icons/core2026/${filename}`;
const expires = String(Math.floor(now() / 1000) + 60);
const requestId = `legacy-ref-${filename.slice(0, 32)}`;
const response = await fetchImpl(`${config.uploadBaseUrl.replace(/\/$/u, '')}${pathname}`, {
method: 'PUT',
headers: {
'content-type': image.contentType,
'x-image-client': 'core2026',
'x-image-expires': expires,
'x-image-request-id': requestId,
'x-image-signature': uploadSignature(
config.uploadSecret,
expires,
requestId,
pathname,
image.contentType,
image.body
),
},
body: new Uint8Array(image.body),
});
if (!response.ok) {
throw new Error(`member.${memberNo}.PICTURE upload failed with HTTP ${response.status}`);
}
const picture = `users/core2026/${filename}`;
const payload: unknown = await response.json();
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== `icons/${picture}`) {
throw new Error(`member.${memberNo}.PICTURE upload returned an unexpected path`);
}
return picture;
};
const mapWithConcurrency = async <T, R>(
values: readonly T[],
concurrency: number,
mapper: (value: T) => Promise<R>
): Promise<R[]> => {
const results = new Array<R>(values.length);
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (nextIndex < values.length) {
const index = nextIndex++;
results[index] = await mapper(values[index]!);
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, worker));
return results;
};
export const prepareLegacyUserIcons = async (
rows: readonly SourceRow[],
config: LegacyUserIconTransferConfig | undefined,
apply: boolean,
options: { fetchImpl?: typeof fetch; now?: () => number; concurrency?: number } = {}
): Promise<LegacyUserIconPreparation> => {
const customRows = rows.filter((row) => (row.PICTURE ?? 'default.jpg') !== 'default.jpg');
if (customRows.length > 0 && !config) {
throw new Error('Gateway source has custom icons but gateway.userIcons is not configured');
}
if (!config) {
return {
icons: new Map(),
rejected: new Map(),
counts: { custom: 0, legacyFiles: 0, existingUploads: 0, uploaded: 0, rejected: 0 },
};
}
if (config.uploadSecret.length < 32) {
throw new Error('gateway.userIcons.uploadSecretFile must contain at least 32 characters');
}
const fetchImpl = options.fetchImpl ?? fetch;
const now = options.now ?? Date.now;
const exclusions = new Set(config.excludedMemberNumbers);
if (exclusions.size !== config.excludedMemberNumbers.length) {
throw new Error('gateway.userIcons.excludedMemberNumbers must not contain duplicates');
}
const validated = await mapWithConcurrency(customRows, options.concurrency ?? 8, async (row) => {
const memberNo = toNumber(row.NO, 'member.NO');
const sourcePicture = toStringValue(row.PICTURE, `member.${memberNo}.PICTURE`);
const normalizedSourcePicture = normalizeLegacyIconPicture(sourcePicture);
const sourceImageServer = toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`);
const fallbackCreatedAt = toDate(row.REG_DATE, `member.${memberNo}.REG_DATE`);
try {
if (REMOTE_PICTURE.test(normalizedSourcePicture)) {
const image = await fetchExistingIcon(config, normalizedSourcePicture, memberNo, fetchImpl);
if (exclusions.has(memberNo)) {
throw new Error(`member.${memberNo} is configured as excluded but its icon is valid`);
}
return {
kind: 'icon' as const,
base: {
memberNo,
userId: legacyUserId(memberNo),
sourcePicture,
normalizedSourcePicture,
sourceImageServer,
imageServer: 0 as const,
createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt),
source: 'existing-upload' as const,
sha256: image.sha256,
},
image,
picture: normalizedSourcePicture,
};
}
if (sourceImageServer !== 1) {
throw new Error(`member.${memberNo}.PICTURE has an unsupported IMGSVR value`);
}
const image = await readLegacyIcon(config.sourceDirectory, normalizedSourcePicture, memberNo);
if (exclusions.has(memberNo)) {
throw new Error(`member.${memberNo} is configured as excluded but its icon is valid`);
}
return {
kind: 'icon' as const,
base: {
memberNo,
userId: legacyUserId(memberNo),
sourcePicture,
normalizedSourcePicture,
sourceImageServer,
imageServer: 0 as const,
createdAt: iconCreatedAt(sourcePicture, fallbackCreatedAt),
source: 'legacy-file' as const,
sha256: image.sha256,
},
image,
picture: `users/core2026/${deterministicUploadName(memberNo, normalizedSourcePicture, image)}`,
};
} catch (error) {
if (!(error instanceof LegacyUserIconValidationError) || !exclusions.has(memberNo)) throw error;
return {
kind: 'rejected' as const,
rejected: {
memberNo,
userId: legacyUserId(memberNo),
sourcePicture,
normalizedSourcePicture,
sourceImageServer,
reason: error.message,
} satisfies RejectedLegacyUserIcon,
};
}
});
const missingExclusions = [...exclusions].filter(
(memberNo) => !validated.some((result) => result.kind === 'rejected' && result.rejected.memberNo === memberNo)
);
if (missingExclusions.length) {
throw new Error(`Configured user-icon exclusions were not rejected: ${missingExclusions.join(', ')}`);
}
const validIcons = validated.filter((result) => result.kind === 'icon');
const rejectedIcons = validated.filter((result) => result.kind === 'rejected').map((result) => result.rejected);
const pictures = new Map<string, number>();
for (const icon of validIcons) {
const owner = pictures.get(icon.picture);
if (owner !== undefined && owner !== icon.base.memberNo) {
throw new Error('Legacy user icon picture is shared by multiple source accounts');
}
pictures.set(icon.picture, icon.base.memberNo);
}
const prepared = await mapWithConcurrency(validIcons, options.concurrency ?? 8, async (icon) => {
const picture =
apply && icon.base.source === 'legacy-file'
? await uploadLegacyIcon(
config,
icon.base.memberNo,
icon.base.normalizedSourcePicture,
icon.image,
fetchImpl,
now
)
: icon.picture;
return { ...icon.base, picture } satisfies PreparedLegacyUserIcon;
});
return {
icons: new Map(prepared.map((icon) => [icon.memberNo, icon])),
rejected: new Map(rejectedIcons.map((icon) => [icon.memberNo, icon])),
counts: {
custom: validated.length,
legacyFiles: prepared.filter((icon) => icon.source === 'legacy-file').length,
existingUploads: prepared.filter((icon) => icon.source === 'existing-upload').length,
uploaded: apply ? prepared.filter((icon) => icon.source === 'legacy-file').length : 0,
rejected: rejectedIcons.length,
},
};
};
export const syncRejectedUserIcons = async (
target: PoolClient,
rejected: readonly RejectedLegacyUserIcon[],
migratedAt: Date
): Promise<RejectedLegacyUserIconSyncCounts> => {
if (rejected.length === 0) return { currentReset: 0, targetPreserved: 0 };
const accounts = await target.query<{ id: string; picture: string; image_server: number }>(
`SELECT "id", "picture", "image_server" FROM "app_user" WHERE "id" = ANY($1::text[]) FOR UPDATE`,
[rejected.map((icon) => icon.userId)]
);
const byId = new Map(accounts.rows.map((account) => [account.id, account]));
const counts = { currentReset: 0, targetPreserved: 0 };
for (const icon of rejected) {
const account = byId.get(icon.userId);
if (!account) throw new Error(`Imported member account is missing for member.${icon.memberNo}`);
const sourceMatchesCurrent =
account.picture === icon.sourcePicture || account.picture === icon.normalizedSourcePicture;
if (sourceMatchesCurrent) {
const reset = await target.query(
`UPDATE "app_user"
SET "picture" = 'default.jpg', "image_server" = 0,
"icon_revision" = GREATEST(
COALESCE("icon_revision", "icon_updated_at", "created_at"),
$2::timestamptz
)
WHERE "id" = $1 AND "picture" = $3 AND "image_server" = $4`,
[icon.userId, migratedAt, account.picture, account.image_server]
);
if (reset.rowCount !== 1) {
throw new Error(`Target account icon changed concurrently for member.${icon.memberNo}`);
}
counts.currentReset += 1;
} else {
counts.targetPreserved += 1;
}
}
return counts;
};
export const syncImportedUserIcons = async (
target: PoolClient,
icons: readonly PreparedLegacyUserIcon[],
migratedAt: Date
): Promise<LegacyUserIconSyncCounts> => {
if (icons.length === 0) {
return { currentLinked: 0, libraryInserted: 0, libraryRetired: 0, targetPreserved: 0 };
}
const userIds = icons.map((icon) => icon.userId);
const accounts = await target.query<{ id: string; picture: string; image_server: number }>(
`SELECT "id", "picture", "image_server" FROM "app_user" WHERE "id" = ANY($1::text[]) FOR UPDATE`,
[userIds]
);
const byId = new Map(accounts.rows.map((account) => [account.id, account]));
const collisions = await target.query<{ picture: string }>(
`SELECT imported."picture"
FROM "user_icon" AS existing
JOIN unnest($1::text[], $2::text[]) AS imported("user_id", "picture")
ON imported."picture" = existing."picture"
WHERE existing."user_id" <> imported."user_id"
LIMIT 1`,
[userIds, icons.map((icon) => icon.picture)]
);
if (collisions.rowCount) {
throw new Error('Legacy user icon picture is already owned by another target account');
}
const counts: LegacyUserIconSyncCounts = {
currentLinked: 0,
libraryInserted: 0,
libraryRetired: 0,
targetPreserved: 0,
};
for (const icon of icons) {
const account = byId.get(icon.userId);
if (!account) throw new Error(`Imported member account is missing for member.${icon.memberNo}`);
const sourceMatchesCurrent =
account.picture === icon.sourcePicture || account.picture === icon.normalizedSourcePicture;
if (sourceMatchesCurrent && (account.picture !== icon.picture || account.image_server !== 0)) {
const linked = await target.query(
`UPDATE "app_user"
SET "picture" = $2, "image_server" = 0,
"icon_revision" = GREATEST(
COALESCE("icon_revision", "icon_updated_at", "created_at"),
$3::timestamptz
)
WHERE "id" = $1 AND "picture" = $4 AND "image_server" = $5`,
[icon.userId, icon.picture, migratedAt, account.picture, account.image_server]
);
if (linked.rowCount !== 1) {
throw new Error(`Target account icon changed concurrently for member.${icon.memberNo}`);
}
account.picture = icon.picture;
account.image_server = 0;
counts.currentLinked += 1;
} else if (!sourceMatchesCurrent && account.picture !== icon.picture) {
counts.targetPreserved += 1;
}
const retiredAt = account.picture === 'default.jpg' ? migratedAt : null;
const inserted = await target.query(
`INSERT INTO "user_icon" ("user_id", "picture", "image_server", "created_at", "retired_at")
VALUES ($1, $2, 0, $3, $4)
ON CONFLICT ("picture") DO NOTHING`,
[icon.userId, icon.picture, icon.createdAt, retiredAt]
);
counts.libraryInserted += inserted.rowCount ?? 0;
if (retiredAt && inserted.rowCount) counts.libraryRetired += 1;
}
return counts;
};
+21 -2
View File
@@ -6,6 +6,7 @@ import { migrateGateway, type MigrationSummary } from './gateway.js';
import type { MigrationMode } from './incremental.js'; import type { MigrationMode } from './incremental.js';
import type { ResolvedMigrationPlan, ResolvedMigrationStage } from './config.js'; import type { ResolvedMigrationPlan, ResolvedMigrationStage } from './config.js';
import { migrationInventoryForStage } from './inventory.js'; import { migrationInventoryForStage } from './inventory.js';
import { prepareLegacyUserIcons } from './legacyUserIcons.js';
export interface PlanRunSummary { export interface PlanRunSummary {
command: 'run-plan'; command: 'run-plan';
@@ -23,6 +24,7 @@ export interface PlanRunSummary {
interface StagePreflight { interface StagePreflight {
battleResults?: { seasons: number; files: number; bytes: number }; battleResults?: { seasons: number; files: number; bytes: number };
battleResultManifests?: readonly BattleResultSeasonManifest[]; battleResultManifests?: readonly BattleResultSeasonManifest[];
userIcons?: { custom: number; legacyFiles: number; existingUploads: number; rejected: number };
} }
const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePreflight> => { const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePreflight> => {
@@ -76,7 +78,23 @@ const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePrefl
if (!targetReady.rows[0]?.table_name) { if (!targetReady.rows[0]?.table_name) {
throw new Error(`Target migrations are not current for ${stage.name}; missing ${targetDataTable}`); throw new Error(`Target migrations are not current for ${stage.name}; missing ${targetDataTable}`);
} }
if (stage.kind === 'game' && stage.battleResults) { if (stage.kind === 'gateway') {
const iconRows = await querySource(
source,
`SELECT NO, PICTURE, IMGSVR, REG_DATE
FROM member WHERE PICTURE <> 'default.jpg' ORDER BY NO`
);
const prepared = await prepareLegacyUserIcons(iconRows, stage.userIcons, false);
return {
userIcons: {
custom: prepared.counts.custom,
legacyFiles: prepared.counts.legacyFiles,
existingUploads: prepared.counts.existingUploads,
rejected: prepared.counts.rejected,
},
};
}
if (stage.battleResults) {
const battleResultReady = await target.query<{ table_name: string | null }>( const battleResultReady = await target.query<{ table_name: string | null }>(
'SELECT to_regclass($1) AS table_name', 'SELECT to_regclass($1) AS table_name',
['legacy_archive.general_battle_result'] ['legacy_archive.general_battle_result']
@@ -113,6 +131,7 @@ export const checkMigrationPlan = async (plan: ResolvedMigrationPlan): Promise<R
status: 'READY', status: 'READY',
inventory: migrationInventoryForStage(stage), inventory: migrationInventoryForStage(stage),
...(preflight.battleResults ? { battleResults: preflight.battleResults } : {}), ...(preflight.battleResults ? { battleResults: preflight.battleResults } : {}),
...(preflight.userIcons ? { userIcons: preflight.userIcons } : {}),
}); });
} }
return { return {
@@ -140,7 +159,7 @@ export const runMigrationPlan = async (
const execution = { mode, source: stage.sourceIdentity } as const; const execution = { mode, source: stage.sourceIdentity } as const;
const summary = const summary =
stage.kind === 'gateway' stage.kind === 'gateway'
? await migrateGateway(source, target, apply, migratedAt, execution) ? await migrateGateway(source, target, apply, migratedAt, execution, stage.userIcons)
: await migrateGame(source, target, apply, stage.profile!, execution); : await migrateGame(source, target, apply, stage.profile!, execution);
const battleResults = const battleResults =
stage.kind === 'game' && stage.battleResults stage.kind === 'game' && stage.battleResults
@@ -17,6 +17,8 @@ const writeFixture = async (mode = 0o600): Promise<string> => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'sammo-legacy-plan-')); const directory = await mkdtemp(path.join(os.tmpdir(), 'sammo-legacy-plan-'));
workDirectories.push(directory); workDirectories.push(directory);
await writeFile(path.join(directory, 'mysql-password'), 'secret-value\n', { mode: 0o600 }); await writeFile(path.join(directory, 'mysql-password'), 'secret-value\n', { mode: 0o600 });
await writeFile(path.join(directory, 'image-upload-secret'), `${'u'.repeat(32)}\n`, { mode: 0o600 });
const iconDirectory = await mkdtemp(path.join(directory, 'icons-'));
const configPath = path.join(directory, 'migration-plan.json'); const configPath = path.join(directory, 'migration-plan.json');
await writeFile( await writeFile(
configPath, configPath,
@@ -30,6 +32,12 @@ const writeFixture = async (mode = 0o600): Promise<string> => {
user: 'migration_reader', user: 'migration_reader',
passwordFile: './mysql-password', passwordFile: './mysql-password',
}, },
userIcons: {
sourceDirectory: iconDirectory,
uploadBaseUrl: 'https://sam-image.hided.net',
publicBaseUrl: 'https://sam-image.hided.net/icons',
uploadSecretFile: './image-upload-secret',
},
targetUrlEnv: 'TEST_GATEWAY_DATABASE_URL', targetUrlEnv: 'TEST_GATEWAY_DATABASE_URL',
}, },
}), }),
@@ -51,6 +59,11 @@ describe('legacy migration plan config', () => {
expect(source.username).toBe('migration_reader'); expect(source.username).toBe('migration_reader');
expect(source.password).toBe('secret-value'); expect(source.password).toBe('secret-value');
expect(plan.stages[0]!.sourceIdentity.key).toBe('fixture-cutover:gateway'); expect(plan.stages[0]!.sourceIdentity.key).toBe('fixture-cutover:gateway');
expect(plan.stages[0]!.userIcons).toMatchObject({
uploadBaseUrl: 'https://sam-image.hided.net',
publicBaseUrl: 'https://sam-image.hided.net/icons',
uploadSecret: 'u'.repeat(32),
});
}); });
it('rejects a config readable by group or other users', async () => { it('rejects a config readable by group or other users', async () => {
+26 -1
View File
@@ -3,7 +3,13 @@ import type { Pool as PgPool, PoolClient, QueryResult } from 'pg';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { upsertRows } from '../src/db.js'; import { upsertRows } from '../src/db.js';
import { mapMember, MEMBER_PRESERVED_COLUMNS, migrateGateway, preflightMemberConflicts } from '../src/gateway.js'; import {
mapMember,
MEMBER_PRESERVED_COLUMNS,
migrateGateway,
normalizeLegacyIconPicture,
preflightMemberConflicts,
} from '../src/gateway.js';
const memberRow = (overrides: Record<string, unknown> = {}) => ({ const memberRow = (overrides: Record<string, unknown> = {}) => ({
NO: 7, NO: 7,
@@ -44,6 +50,25 @@ describe('legacy gateway member migration', () => {
}); });
}); });
it('removes only the Ref cache marker while preserving the original icon metadata', () => {
const mapped = mapMember(
memberRow({ PICTURE: 'users/core/' + 'a'.repeat(32) + '.png?=20260809', IMGSVR: 0 }),
new Date('2026-08-17T00:00:00.000Z'),
null
);
expect(normalizeLegacyIconPicture('legacy.png?=20260809')).toBe('legacy.png');
expect(normalizeLegacyIconPicture('literal.png?other')).toBe('literal.png?other');
expect(mapped).toMatchObject({
picture: 'users/core/' + 'a'.repeat(32) + '.png',
image_server: 0,
});
expect((mapped.legacy_data as { value: unknown }).value).toMatchObject({
picture: 'users/core/' + 'a'.repeat(32) + '.png?=20260809',
imageServer: 0,
});
});
it('preserves target-owned credentials and OAuth state on a repeated member upsert', async () => { it('preserves target-owned credentials and OAuth state on a repeated member upsert', async () => {
const query = vi.fn(async (_sql: string, _values?: unknown[]) => ({ rows: [], rowCount: 0 })); const query = vi.fn(async (_sql: string, _values?: unknown[]) => ({ rows: [], rowCount: 0 }));
const client = { query } as unknown as PoolClient; const client = { query } as unknown as PoolClient;
@@ -0,0 +1,109 @@
import { Pool } from 'pg';
import { describe, expect, it } from 'vitest';
import { legacyUserId } from '../src/identity.js';
import { syncImportedUserIcons, syncRejectedUserIcons, type PreparedLegacyUserIcon } from '../src/legacyUserIcons.js';
const databaseUrl = process.env.LEGACY_ICON_TEST_DATABASE_URL;
const imported = (memberNo: number, sourcePicture: string, picture: string): PreparedLegacyUserIcon => ({
memberNo,
userId: legacyUserId(memberNo),
sourcePicture,
normalizedSourcePicture: sourcePicture.replace(/\?=[0-9]{8}$/u, ''),
sourceImageServer: 1,
picture,
imageServer: 0,
createdAt: new Date('2026-08-09T00:00:00.000Z'),
source: 'legacy-file',
sha256: 'a'.repeat(64),
});
describe.skipIf(!databaseUrl)('legacy user icon PostgreSQL synchronization', () => {
it('updates only an unchanged Ref selection and preserves newer Core state', async () => {
const pool = new Pool({ connectionString: databaseUrl });
const client = await pool.connect();
const icons = [
imported(700_001, 'first.png?=20260809', `users/core2026/${'1'.repeat(32)}.png`),
imported(700_002, 'second.png?=20260809', `users/core2026/${'2'.repeat(32)}.png`),
imported(700_003, 'third.png?=20260809', `users/core2026/${'3'.repeat(32)}.png`),
];
try {
await client.query('BEGIN');
for (const [index, icon] of icons.entries()) {
const currentPicture =
index === 0
? icon.sourcePicture
: index === 1
? `users/core2026/${'8'.repeat(32)}.png`
: 'default.jpg';
await client.query(
`INSERT INTO "app_user"
("id", "login_id", "display_name", "password_hash", "password_salt",
"updated_at", "picture", "image_server")
VALUES ($1, $2, $3, 'hash', 'salt', CURRENT_TIMESTAMP, $4, $5)`,
[icon.userId, `icon-test-${index}`, `아이콘테스트-${index}`, currentPicture, index === 0 ? 1 : 0]
);
}
await expect(syncImportedUserIcons(client, icons, new Date('2026-08-24T00:00:00Z'))).resolves.toEqual({
currentLinked: 1,
libraryInserted: 3,
libraryRetired: 1,
targetPreserved: 2,
});
const accounts = await client.query<{ id: string; picture: string; image_server: number }>(
`SELECT "id", "picture", "image_server" FROM "app_user"
WHERE "id" = ANY($1::text[]) ORDER BY "login_id"`,
[icons.map((icon) => icon.userId)]
);
expect(accounts.rows.map(({ picture, image_server: imageServer }) => ({ picture, imageServer }))).toEqual([
{ picture: icons[0]!.picture, imageServer: 0 },
{ picture: `users/core2026/${'8'.repeat(32)}.png`, imageServer: 0 },
{ picture: 'default.jpg', imageServer: 0 },
]);
const library = await client.query<{ picture: string; retired_at: Date | null }>(
`SELECT "picture", "retired_at" FROM "user_icon"
WHERE "user_id" = ANY($1::text[]) ORDER BY "picture"`,
[icons.map((icon) => icon.userId)]
);
expect(library.rows).toHaveLength(3);
expect(library.rows.filter((row) => row.retired_at !== null)).toHaveLength(1);
const rejectedUserId = legacyUserId(700_004);
await client.query(
`INSERT INTO "app_user"
("id", "login_id", "display_name", "password_hash", "password_salt",
"updated_at", "picture", "image_server")
VALUES ($1, 'icon-test-rejected', '아이콘테스트-제외', 'hash', 'salt',
CURRENT_TIMESTAMP, 'invalid.gif?=20260809', 1)`,
[rejectedUserId]
);
await expect(
syncRejectedUserIcons(
client,
[
{
memberNo: 700_004,
userId: rejectedUserId,
sourcePicture: 'invalid.gif?=20260809',
normalizedSourcePicture: 'invalid.gif',
sourceImageServer: 1,
reason: 'not square',
},
],
new Date('2026-08-24T00:00:00Z')
)
).resolves.toEqual({ currentReset: 1, targetPreserved: 0 });
const rejectedAccount = await client.query<{ picture: string; image_server: number }>(
`SELECT "picture", "image_server" FROM "app_user" WHERE "id" = $1`,
[rejectedUserId]
);
expect(rejectedAccount.rows[0]).toEqual({ picture: 'default.jpg', image_server: 0 });
} finally {
await client.query('ROLLBACK');
client.release();
await pool.end();
}
});
});
@@ -0,0 +1,256 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createHash, createHmac } from 'node:crypto';
import type { PoolClient, QueryResult } from 'pg';
import sharp from 'sharp';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { legacyUserId } from '../src/identity.js';
import {
prepareLegacyUserIcons,
syncImportedUserIcons,
syncRejectedUserIcons,
type LegacyUserIconTransferConfig,
type PreparedLegacyUserIcon,
} from '../src/legacyUserIcons.js';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
);
});
const createFixture = async (): Promise<{ config: LegacyUserIconTransferConfig; png: Buffer }> => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'sammo-user-icons-'));
temporaryDirectories.push(directory);
const png = await sharp({ create: { width: 64, height: 64, channels: 4, background: '#336699ff' } })
.png()
.toBuffer();
await writeFile(path.join(directory, 'legacy.png'), png);
return {
config: {
sourceDirectory: directory,
uploadBaseUrl: 'https://upload.test',
publicBaseUrl: 'https://public.test/icons',
uploadSecret: 's'.repeat(32),
excludedMemberNumbers: [],
},
png,
};
};
const sourceRow = (overrides: Record<string, unknown> = {}) => ({
NO: 7,
PICTURE: 'legacy.png?=20260809',
IMGSVR: 1,
REG_DATE: '2020-01-01 00:00:00',
...overrides,
});
describe('legacy user icon transfer', () => {
it('validates a Ref file and derives a deterministic API path without writing during dry-run', async () => {
const { config } = await createFixture();
const fetchImpl = vi.fn<typeof fetch>();
const first = await prepareLegacyUserIcons([sourceRow()], config, false, { fetchImpl });
const second = await prepareLegacyUserIcons([sourceRow()], config, false, { fetchImpl });
expect(first.counts).toEqual({
custom: 1,
legacyFiles: 1,
existingUploads: 0,
uploaded: 0,
rejected: 0,
});
expect(first.icons.get(7)?.picture).toMatch(/^users\/core2026\/[a-f0-9]{32}\.png$/u);
expect(second.icons.get(7)?.picture).toBe(first.icons.get(7)?.picture);
expect(fetchImpl).not.toHaveBeenCalled();
});
it('uploads through the signed API and accepts only the exact returned path', async () => {
const { config, png } = await createFixture();
const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
const pathname = new URL(String(input)).pathname;
const headers = new Headers(init?.headers);
const expires = headers.get('x-image-expires')!;
const requestId = headers.get('x-image-request-id')!;
const contentType = headers.get('content-type')!;
const expectedSignature = createHmac('sha256', config.uploadSecret)
.update(
`${expires}.${requestId}.${pathname}.${contentType}.${createHash('sha256').update(png).digest('hex')}`
)
.digest('hex');
expect(init?.method).toBe('PUT');
expect(Buffer.from(init?.body as Uint8Array)).toEqual(png);
expect(headers.get('x-image-client')).toBe('core2026');
expect(headers.get('x-image-signature')).toBe(expectedSignature);
return new Response(JSON.stringify({ path: pathname.replace('/v1/uploads/user-icons/', 'icons/users/') }), {
status: 201,
headers: { 'content-type': 'application/json' },
});
});
const result = await prepareLegacyUserIcons([sourceRow()], config, true, {
fetchImpl,
now: () => Date.parse('2026-08-24T00:00:00.000Z'),
});
expect(result.counts.uploaded).toBe(1);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(result.icons.get(7)?.picture).toMatch(/^users\/core2026\/[a-f0-9]{32}\.png$/u);
});
it('validates all source files before starting any permanent upload', async () => {
const { config } = await createFixture();
const fetchImpl = vi.fn<typeof fetch>();
await expect(
prepareLegacyUserIcons(
[sourceRow(), sourceRow({ NO: 8, PICTURE: 'missing.png?=20260809' })],
config,
true,
{ fetchImpl }
)
).rejects.toThrow();
expect(fetchImpl).not.toHaveBeenCalled();
});
it('verifies an existing sam-image object without uploading it again', async () => {
const { config, png } = await createFixture();
const picture = `users/core/${'a'.repeat(32)}.png`;
const fetchImpl = vi.fn<typeof fetch>(async () => new Response(png, { status: 200 }));
const result = await prepareLegacyUserIcons(
[sourceRow({ PICTURE: `${picture}?=20260809`, IMGSVR: 0 })],
config,
true,
{ fetchImpl }
);
expect(result.counts).toEqual({
custom: 1,
legacyFiles: 0,
existingUploads: 1,
uploaded: 0,
rejected: 0,
});
expect(result.icons.get(7)?.picture).toBe(picture);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe(`https://public.test/icons/${picture}`);
});
it('fails closed when custom icons exist without an API transfer configuration', async () => {
await expect(prepareLegacyUserIcons([sourceRow()], undefined, false)).rejects.toThrow(
'gateway.userIcons is not configured'
);
});
it('permits only an explicitly reviewed member exclusion whose bytes remain invalid', async () => {
const { config } = await createFixture();
const invalidGif = await sharp({
create: { width: 64, height: 65, channels: 4, background: '#336699ff' },
})
.gif()
.toBuffer();
await writeFile(path.join(config.sourceDirectory, 'invalid.gif'), invalidGif);
config.excludedMemberNumbers = [7];
const result = await prepareLegacyUserIcons([sourceRow({ PICTURE: 'invalid.gif?=20260809' })], config, true, {
fetchImpl: vi.fn<typeof fetch>(),
});
expect(result.counts).toEqual({
custom: 1,
legacyFiles: 0,
existingUploads: 0,
uploaded: 0,
rejected: 1,
});
expect(result.rejected.get(7)?.reason).toContain('square image');
expect(result.icons.size).toBe(0);
});
it('rejects a stale exclusion when the configured member icon is valid', async () => {
const { config } = await createFixture();
config.excludedMemberNumbers = [7];
await expect(prepareLegacyUserIcons([sourceRow()], config, false)).rejects.toThrow(
'configured as excluded but its icon is valid'
);
});
it('links an unchanged Ref selection while preserving newer Core selections', async () => {
const imported = (memberNo: number, sourcePicture: string, picture: string): PreparedLegacyUserIcon => ({
memberNo,
userId: legacyUserId(memberNo),
sourcePicture,
normalizedSourcePicture: sourcePicture.replace(/\?=[0-9]{8}$/u, ''),
sourceImageServer: 1,
picture,
imageServer: 0,
createdAt: new Date('2026-08-09T00:00:00.000Z'),
source: 'legacy-file',
sha256: 'a'.repeat(64),
});
const icons = [
imported(7, 'first.png?=20260809', `users/core2026/${'1'.repeat(32)}.png`),
imported(8, 'second.png?=20260809', `users/core2026/${'2'.repeat(32)}.png`),
imported(9, 'third.png?=20260809', `users/core2026/${'3'.repeat(32)}.png`),
];
const query = vi.fn(async (sql: string, _parameters?: readonly unknown[]) => {
if (sql.includes('FROM "app_user"')) {
return {
rows: [
{ id: legacyUserId(7), picture: 'first.png?=20260809', image_server: 1 },
{ id: legacyUserId(8), picture: `users/core2026/${'8'.repeat(32)}.png`, image_server: 0 },
{ id: legacyUserId(9), picture: 'default.jpg', image_server: 0 },
],
rowCount: 3,
} as QueryResult;
}
if (sql.includes('JOIN unnest')) return { rows: [], rowCount: 0 } as unknown as QueryResult;
return { rows: [], rowCount: 1 } as unknown as QueryResult;
});
const target = { query } as unknown as PoolClient;
await expect(syncImportedUserIcons(target, icons, new Date('2026-08-24T00:00:00Z'))).resolves.toEqual({
currentLinked: 1,
libraryInserted: 3,
libraryRetired: 1,
targetPreserved: 2,
});
const inserts = query.mock.calls.filter(([sql]) => String(sql).includes('INSERT INTO "user_icon"'));
expect(inserts).toHaveLength(3);
expect(inserts[2]?.[1]?.[3]).toEqual(new Date('2026-08-24T00:00:00Z'));
});
it('resets only the still-selected invalid Ref icon', async () => {
const userId = legacyUserId(7);
const rejected = {
memberNo: 7,
userId,
sourcePicture: 'invalid.gif?=20260809',
normalizedSourcePicture: 'invalid.gif',
sourceImageServer: 1,
reason: 'not square',
};
const query = vi.fn(async (sql: string, _parameters?: readonly unknown[]) => {
if (sql.includes('FROM "app_user"')) {
return {
rows: [{ id: userId, picture: rejected.sourcePicture, image_server: 1 }],
rowCount: 1,
} as QueryResult;
}
return { rows: [], rowCount: 1 } as unknown as QueryResult;
});
await expect(
syncRejectedUserIcons({ query } as unknown as PoolClient, [rejected], new Date('2026-08-24T00:00:00Z'))
).resolves.toEqual({ currentReset: 1, targetPreserved: 0 });
expect(String(query.mock.calls[1]?.[0])).toContain(`SET "picture" = 'default.jpg'`);
});
});