feat: add user icon library and in-game selection
This commit is contained in:
@@ -174,13 +174,33 @@ const resolvePenalty = (penalty: unknown): Record<string, number> => {
|
||||
};
|
||||
|
||||
export const generalRouter = router({
|
||||
adjustIcon: engineAuthedProcedure.mutation(({ ctx }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return adjustAccountIconForUser(ctx, userId);
|
||||
}),
|
||||
adjustIcon: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({ iconId: z.string().uuid().optional(), clientRequestId: z.string().uuid().optional() }).optional()
|
||||
)
|
||||
.mutation(({ ctx, input }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
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)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
|
||||
}
|
||||
return adjustAccountIconForUser(
|
||||
ctx,
|
||||
userId,
|
||||
selected
|
||||
? {
|
||||
picture: selected.picture,
|
||||
imageServer: selected.imageServer,
|
||||
revision: ctx.auth?.user.iconUpdatedAt ?? selected.createdAt,
|
||||
}
|
||||
: undefined,
|
||||
true,
|
||||
input?.clientRequestId ?? ctx.requestId
|
||||
);
|
||||
}),
|
||||
me: authedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
@@ -296,6 +316,12 @@ export const generalRouter = router({
|
||||
item: normalizeItemCode(general.itemCode),
|
||||
},
|
||||
},
|
||||
iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []),
|
||||
canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false,
|
||||
iconChangeAvailableAt:
|
||||
typeof metaRecord.generalIconChangedAt === 'string'
|
||||
? new Date(new Date(metaRecord.generalIconChangedAt).getTime() + 24 * 60 * 60 * 1000).toISOString()
|
||||
: null,
|
||||
city,
|
||||
nation,
|
||||
settings,
|
||||
|
||||
@@ -329,6 +329,8 @@ export const joinRouter = router({
|
||||
id: ctx.auth?.user.id ?? '',
|
||||
displayName: ctx.auth?.user.displayName ?? '',
|
||||
canCreateGeneral: ctx.auth?.identity?.canCreateGeneral !== false,
|
||||
icons: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []),
|
||||
preferredPicture: ctx.auth?.user.picture ?? 'default.jpg',
|
||||
},
|
||||
personalities: [{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' }, ...personalities],
|
||||
warSpecials,
|
||||
@@ -383,6 +385,7 @@ export const joinRouter = router({
|
||||
z.object({
|
||||
uniqueName: z.string().min(1).max(20),
|
||||
personality: z.string().min(1),
|
||||
iconId: z.string().uuid().optional(),
|
||||
clientRequestId: z.string().uuid().optional(),
|
||||
})
|
||||
)
|
||||
@@ -392,6 +395,10 @@ export const joinRouter = router({
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const userId = auth.user.id;
|
||||
const selectedIcon = input.iconId ? auth.user.icons?.find((icon) => icon.id === input.iconId) : undefined;
|
||||
if (input.iconId && (!selectedIcon || auth.user.canUseGeneralPicture === false)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
|
||||
}
|
||||
if (auth.identity?.canCreateGeneral === false) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
@@ -407,6 +414,13 @@ export const joinRouter = router({
|
||||
uniqueName: input.uniqueName,
|
||||
personality: input.personality,
|
||||
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
|
||||
...(selectedIcon
|
||||
? {
|
||||
ownerPicture: selectedIcon.picture,
|
||||
ownerImageServer: selectedIcon.imageServer,
|
||||
ownerIconRevision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return resolveSelectionCommandResult(result, 'selectPoolCreate');
|
||||
}),
|
||||
@@ -446,6 +460,7 @@ export const joinRouter = router({
|
||||
strength: z.number().int(),
|
||||
intel: z.number().int(),
|
||||
pic: z.boolean(),
|
||||
iconId: z.string().uuid().optional(),
|
||||
character: zJoinPersonality,
|
||||
clientRequestId: z.string().uuid().optional(),
|
||||
inheritSpecial: z.string().optional(),
|
||||
@@ -466,7 +481,19 @@ export const joinRouter = router({
|
||||
});
|
||||
}
|
||||
const userId = auth.user.id;
|
||||
const accountIcon = input.pic ? await loadAuthoritativeAccountIcon(ctx, userId) : null;
|
||||
const selectedIcon = input.iconId ? auth.user.icons?.find((icon) => icon.id === input.iconId) : undefined;
|
||||
if (input.iconId && (!selectedIcon || auth.user.canUseGeneralPicture === false)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
|
||||
}
|
||||
const accountIcon = input.pic
|
||||
? selectedIcon
|
||||
? {
|
||||
picture: selectedIcon.picture,
|
||||
imageServer: selectedIcon.imageServer,
|
||||
revision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt,
|
||||
}
|
||||
: await loadAuthoritativeAccountIcon(ctx, userId)
|
||||
: null;
|
||||
const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
|
||||
const result = await requestJoinCreateCommand(ctx, {
|
||||
type: 'joinCreateGeneral',
|
||||
|
||||
@@ -39,14 +39,19 @@ export const loadAuthoritativeAccountIcon = async (
|
||||
|
||||
export const adjustAccountIconForUser = async (
|
||||
ctx: GameApiContext,
|
||||
userId: string
|
||||
userId: string,
|
||||
selected?: AccountIconProjection,
|
||||
enforceCooldown = true,
|
||||
requestKey?: string
|
||||
): Promise<{
|
||||
ok: true;
|
||||
generalId: number | null;
|
||||
updated: boolean;
|
||||
}> => {
|
||||
const projection = await loadAuthoritativeAccountIcon(ctx, userId);
|
||||
const requestId = `general:adjustIcon:${userId}:${projection.revision}`;
|
||||
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}`;
|
||||
try {
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralIcon',
|
||||
@@ -55,6 +60,7 @@ export const adjustAccountIconForUser = async (
|
||||
picture: projection.picture,
|
||||
imageServer: projection.imageServer,
|
||||
iconRevision: projection.revision,
|
||||
enforceCooldown,
|
||||
});
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -423,6 +423,38 @@ describe('appRouter', () => {
|
||||
picture: 'latest.png',
|
||||
imageServer: 1,
|
||||
iconRevision: currentAccountIcon.revision,
|
||||
enforceCooldown: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a fresh client request id when selecting a registered in-game icon', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const clientRequestId = 'b24454da-d0ab-48d2-a7d5-e2e5aaf83ba4';
|
||||
const requestId = `general:adjustIcon:user-1:manual:${clientRequestId}`;
|
||||
transport.setCommandResult(requestId, {
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: true,
|
||||
generalId: 1,
|
||||
updated: true,
|
||||
});
|
||||
const auth = buildAuth();
|
||||
auth.user.icons = [
|
||||
{
|
||||
id: '3f804277-584f-4f44-b39c-9ecf40d1ed31',
|
||||
picture: 'manual.png',
|
||||
imageServer: 1,
|
||||
createdAt: '2026-07-30T09:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const caller = appRouter.createCaller(buildContext({ auth, transport }));
|
||||
|
||||
await caller.general.adjustIcon({ iconId: auth.user.icons[0]!.id, clientRequestId });
|
||||
|
||||
expect(transport.commands.at(-1)?.command).toMatchObject({
|
||||
requestId,
|
||||
picture: 'manual.png',
|
||||
imageServer: 1,
|
||||
enforceCooldown: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -590,6 +622,74 @@ describe('appRouter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a general with the selected authenticated icon and rejects another icon id', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const clientRequestId = '924454da-d0ab-48d2-a7d5-e2e5aaf83ba4';
|
||||
transport.setCommandResult(`join-create:user-1:${clientRequestId}`, {
|
||||
type: 'joinCreateGeneral',
|
||||
ok: true,
|
||||
generalId: 43,
|
||||
});
|
||||
const auth = buildAuth();
|
||||
auth.user.iconUpdatedAt = '2026-08-01T09:00:00.000Z';
|
||||
auth.user.icons = [
|
||||
{
|
||||
id: '3f804277-584f-4f44-b39c-9ecf40d1ed31',
|
||||
picture: 'selected.png',
|
||||
imageServer: 1,
|
||||
createdAt: '2026-07-30T09:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const caller = appRouter.createCaller(buildContext({ state: buildWorldState(), auth, transport }));
|
||||
|
||||
await caller.join.createGeneral({
|
||||
name: '선택전콘',
|
||||
leadership: 55,
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
pic: true,
|
||||
iconId: auth.user.icons[0]!.id,
|
||||
character: 'Random',
|
||||
clientRequestId,
|
||||
});
|
||||
expect(transport.commands.at(-1)?.command).toMatchObject({
|
||||
ownerPicture: 'selected.png',
|
||||
ownerImageServer: 1,
|
||||
ownerIconRevision: auth.user.iconUpdatedAt,
|
||||
});
|
||||
|
||||
const poolRequestId = 'a24454da-d0ab-48d2-a7d5-e2e5aaf83ba4';
|
||||
transport.setCommandResult(`select-pool:user-1:${poolRequestId}:create`, {
|
||||
type: 'selectPoolCreate',
|
||||
ok: true,
|
||||
generalId: 44,
|
||||
});
|
||||
await caller.join.selectPoolGeneral({
|
||||
uniqueName: '선택풀장수',
|
||||
personality: 'Random',
|
||||
iconId: auth.user.icons[0]!.id,
|
||||
clientRequestId: poolRequestId,
|
||||
});
|
||||
expect(transport.commands.at(-1)?.command).toMatchObject({
|
||||
type: 'selectPoolCreate',
|
||||
ownerPicture: 'selected.png',
|
||||
ownerImageServer: 1,
|
||||
ownerIconRevision: auth.user.iconUpdatedAt,
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.join.createGeneral({
|
||||
name: '타인전콘',
|
||||
leadership: 55,
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
pic: true,
|
||||
iconId: 'f6af46a2-809a-481d-b66d-0f7bbb706780',
|
||||
character: 'Random',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('queues turn daemon run commands', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const caller = appRouter.createCaller(buildContext({ transport }));
|
||||
|
||||
Reference in New Issue
Block a user