merge: 최신 main을 메시지 tombstone 변경에 다시 통합한다

This commit is contained in:
2026-08-24 08:42:38 +00:00
16 changed files with 245 additions and 78 deletions
+37 -9
View File
@@ -695,7 +695,13 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
export const generalRouter = router({
adjustIcon: engineAuthedProcedure
.input(
z.object({ iconId: z.string().uuid().optional(), clientRequestId: z.string().uuid().optional() }).optional()
z
.object({
iconId: z.string().uuid().optional(),
resetToDefault: z.literal(true).optional(),
clientRequestId: z.string().uuid().optional(),
})
.optional()
)
.mutation(({ ctx, input }) => {
const userId = ctx.auth?.user.id;
@@ -703,19 +709,41 @@ export const generalRouter = router({
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const selected = input?.iconId ? ctx.auth?.user.icons?.find((icon) => icon.id === input.iconId) : undefined;
if (input?.iconId && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) {
const resetToDefault = input?.resetToDefault === true;
if (resetToDefault && input?.iconId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘 선택과 기본 아이콘 초기화를 함께 요청할 수 없습니다.' });
}
if (!resetToDefault && !input?.iconId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '적용할 활성 전용 아이콘을 선택해 주세요.' });
}
if (
resetToDefault &&
(ctx.auth?.user.picture !== 'default.jpg' || ctx.auth?.user.imageServer !== 0)
) {
throw new TRPCError({ code: 'FORBIDDEN', message: '현재 계정 아이콘이 기본 아이콘이 아닙니다.' });
}
if (!resetToDefault && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) {
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
}
const iconRevision = ctx.auth?.user.iconUpdatedAt ?? (resetToDefault ? undefined : selected!.createdAt);
if (!iconRevision) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '계정 아이콘 변경 시각을 확인할 수 없습니다.' });
}
const projection = resetToDefault
? {
picture: 'default.jpg',
imageServer: 0,
revision: iconRevision,
}
: {
picture: selected!.picture,
imageServer: selected!.imageServer,
revision: iconRevision,
};
return adjustAccountIconForUser(
ctx,
userId,
selected
? {
picture: selected.picture,
imageServer: selected.imageServer,
revision: ctx.auth?.user.iconUpdatedAt ?? selected.createdAt,
}
: undefined,
projection,
true,
input?.clientRequestId ?? ctx.requestId
);
+7 -6
View File
@@ -14,7 +14,6 @@ import {
WAR_TRAIT_KEYS,
} from '@sammo-ts/logic';
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { getSelectionPoolStatus, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
import {
@@ -515,15 +514,17 @@ export const joinRouter = router({
if (input.iconId && (!selectedIcon || auth.user.canUseGeneralPicture === false)) {
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
}
const accountIcon = input.pic
? selectedIcon
// 유저 장수에는 인증 token의 활성 전용 아이콘을 명시적으로 고른 경우만
// 그림을 적용한다. Gateway 대표 그림은 shared preset일 수 있으므로
// iconId 없는 fallback으로 사용하지 않는다.
const accountIcon =
input.pic && selectedIcon
? {
picture: selectedIcon.picture,
imageServer: selectedIcon.imageServer,
revision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt,
}
: await loadAuthoritativeAccountIcon(ctx, userId)
: null;
: null;
const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
const result = await requestJoinCreateCommand(ctx, {
type: 'joinCreateGeneral',
@@ -535,7 +536,7 @@ export const joinRouter = router({
leadership: input.leadership,
strength: input.strength,
intel: input.intel,
pic: input.pic,
pic: accountIcon !== null,
character: input.character,
profileId: ctx.profile.id,
...(accountIcon
+3 -5
View File
@@ -40,7 +40,7 @@ export const loadAuthoritativeAccountIcon = async (
export const adjustAccountIconForUser = async (
ctx: GameApiContext,
userId: string,
selected?: AccountIconProjection,
selected: AccountIconProjection,
enforceCooldown = true,
requestKey?: string
): Promise<{
@@ -48,10 +48,8 @@ export const adjustAccountIconForUser = async (
generalId: number | null;
updated: boolean;
}> => {
const projection = selected ?? (await loadAuthoritativeAccountIcon(ctx, userId));
const requestId = selected
? `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`
: `general:adjustIcon:${userId}:${projection.revision}`;
const projection = selected;
const requestId = `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`;
try {
const result = await ctx.turnDaemon.requestCommand({
type: 'adjustGeneralIcon',
+50 -39
View File
@@ -388,45 +388,56 @@ describe('appRouter', () => {
});
});
it('applies the current Gateway database icon instead of stale token claims', async () => {
it('rejects icon adjustment without an explicitly selected active icon', async () => {
const transport = new InMemoryTurnDaemonTransport();
const currentAccountIcon = {
revision: '2026-07-31T09:00:00.000Z',
picture: 'latest.png',
imageServer: 1,
};
const auth = buildAuth();
auth.user.picture = 'stale.png';
auth.user.picture = '장수/유비.jpg';
auth.user.imageServer = 0;
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
const requestId = `general:adjustIcon:${auth.user.id}:${currentAccountIcon.revision}`;
const accountIconGet = vi.fn(async () => ({
revision: '2026-07-31T09:00:00.000Z',
picture: '장수/유비.jpg',
imageServer: 0,
}));
const caller = appRouter.createCaller(
buildContext({
auth,
transport,
accountIconGet,
})
);
await expect(caller.general.adjustIcon()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
await expect(caller.general.adjustIcon({ resetToDefault: true })).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect(accountIconGet).not.toHaveBeenCalled();
expect(transport.commands).toHaveLength(0);
});
it('allows an explicit default reset only when the signed account projection is default', async () => {
const transport = new InMemoryTurnDaemonTransport();
const auth = buildAuth();
const revision = '2026-07-31T09:00:00.000Z';
auth.user.picture = 'default.jpg';
auth.user.imageServer = 0;
auth.user.iconUpdatedAt = revision;
const requestId = `general:adjustIcon:${auth.user.id}:manual:${revision}:default.jpg`;
transport.setCommandResult(requestId, {
type: 'adjustGeneralIcon',
ok: true,
generalId: 1,
updated: true,
});
const caller = appRouter.createCaller(
buildContext({
auth,
transport,
currentAccountIcon,
})
);
const caller = appRouter.createCaller(buildContext({ auth, transport }));
await expect(caller.general.adjustIcon()).resolves.toEqual({
await expect(caller.general.adjustIcon({ resetToDefault: true })).resolves.toMatchObject({
ok: true,
generalId: 1,
updated: true,
});
expect(transport.commands.at(-1)?.command).toEqual({
type: 'adjustGeneralIcon',
expect(transport.commands.at(-1)?.command).toMatchObject({
requestId,
userId: auth.user.id,
picture: 'latest.png',
imageServer: 1,
iconRevision: currentAccountIcon.revision,
enforceCooldown: true,
picture: 'default.jpg',
imageServer: 0,
iconRevision: revision,
});
});
@@ -461,13 +472,13 @@ describe('appRouter', () => {
});
});
it('rejects icon adjustment without auth or a current Gateway account', async () => {
it('rejects icon adjustment without auth or a selected icon', async () => {
await expect(appRouter.createCaller(buildContext({ auth: null })).general.adjustIcon()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
await expect(
appRouter.createCaller(buildContext({ auth: buildAuth() })).general.adjustIcon()
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('rejects unauthenticated or game-blocked auth status checks', async () => {
@@ -581,30 +592,30 @@ describe('appRouter', () => {
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision');
});
it('uses the authoritative projection instead of stale token claims for picture creation', async () => {
it('does not apply a shared Gateway representative when no active icon id was selected', async () => {
const transport = new InMemoryTurnDaemonTransport();
const clientRequestId = '824454da-d0ab-48d2-a7d5-e2e5aaf83ba4';
const requestId = `join-create:user-1:${clientRequestId}`;
const revision = '2026-07-31T09:00:00.001Z';
transport.setCommandResult(requestId, {
type: 'joinCreateGeneral',
ok: true,
generalId: 42,
});
const auth = buildAuth();
auth.user.picture = 'stale.png';
auth.user.picture = '장수/유비.jpg';
auth.user.imageServer = 0;
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
const accountIconGet = vi.fn(async () => ({
revision: '2026-07-31T09:00:00.001Z',
picture: '장수/유비.jpg',
imageServer: 0,
}));
const caller = appRouter.createCaller(
buildContext({
state: buildWorldState(),
auth,
transport,
currentAccountIcon: {
revision,
picture: 'latest.png',
imageServer: 1,
},
accountIconGet,
})
);
@@ -618,11 +629,11 @@ describe('appRouter', () => {
clientRequestId,
});
expect(transport.commands.at(-1)?.command).toMatchObject({
ownerPicture: 'latest.png',
ownerImageServer: 1,
ownerIconRevision: revision,
});
expect(accountIconGet).not.toHaveBeenCalled();
expect(transport.commands.at(-1)?.command).toMatchObject({ pic: false });
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerPicture');
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerImageServer');
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision');
});
it('creates a general with the selected authenticated icon and rejects another icon id', async () => {
@@ -257,6 +257,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
const initial = await db.general.findFirstOrThrow({ where: { userId } });
const initialRuntime = runtime!.world.getGeneralById(initial.id);
const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } });
expect(initial).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
const acceptedEvent = await db.inputEvent.findFirstOrThrow({
where: { actorUserId: userId, eventType: 'selectPoolCreate', status: 'SUCCEEDED' },
orderBy: { sequence: 'desc' },
@@ -273,7 +274,8 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
id: initial.id,
userId,
name: initial.name,
imageServer: initial.imageServer,
imageServer: 0,
picture: 'default.jpg',
stats: {
leadership: initial.leadership,
strength: initial.strength,
@@ -385,15 +387,15 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
intel: target.intel,
personalCode: initial.personalCode,
specialCode: target.specialDomestic,
imageServer: target.imageServer,
picture: target.picture,
imageServer: 0,
picture: 'default.jpg',
});
expect(runtime!.world.getGeneralById(initial.id)).toMatchObject({
id: initial.id,
userId,
name: target.generalName,
imageServer: target.imageServer,
picture: target.picture,
imageServer: 0,
picture: 'default.jpg',
stats: {
leadership: target.leadership,
strength: target.strength,