merge: 최신 main을 transport 권한과 durable 검증에 최종 통합한다
This commit is contained in:
@@ -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)})
|
||||||
|
`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,14 +514,16 @@ 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, {
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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=');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ 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 |
|
||||||
@@ -66,6 +66,34 @@ 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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Generated
+3
@@ -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('차단될 메시지');
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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'`);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user