feat: add user icon library and in-game selection

This commit is contained in:
2026-08-01 03:40:36 +00:00
parent 395b60cdbe
commit a275e6a234
26 changed files with 1214 additions and 69 deletions
+33 -7
View File
@@ -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,
+28 -1
View File
@@ -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',
+9 -3
View File
@@ -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({