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
@@ -261,6 +261,7 @@ const zAdjustGeneralIcon = z
picture: z.string().min(1),
imageServer: z.number().int().nonnegative(),
iconRevision: z.string().refine(isCanonicalIsoTimestamp),
enforceCooldown: z.boolean().optional(),
})
.strict();
@@ -312,6 +313,9 @@ const zSelectPoolCreate = z
uniqueName: z.string().min(1).max(20),
personality: z.string().min(1),
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
ownerPicture: z.string().optional(),
ownerImageServer: z.number().int().nonnegative().optional(),
ownerIconRevision: z.string().refine(isCanonicalIsoTimestamp).optional(),
})
.strict();
+11 -2
View File
@@ -515,6 +515,9 @@ export const createGeneralFromSelectionPool = async (options: {
personality: string;
now?: Date;
seedOwnerIdentity?: string | number;
ownerPicture?: string;
ownerImageServer?: number;
ownerIconRevision?: string;
}): Promise<{ ok: true; generalId: number }> => {
const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options;
requirePoolWorld(worldState);
@@ -555,7 +558,10 @@ export const createGeneralFromSelectionPool = async (options: {
);
const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config);
const showImgLevel = asNumber(config.showImgLevel, 0);
const picture = showImgLevel >= 3 ? info.picture : 'default.jpg';
const useOwnerPicture =
showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg';
const picture = useOwnerPicture ? options.ownerPicture! : showImgLevel >= 3 ? info.picture : 'default.jpg';
const imageServer = useOwnerPicture ? (options.ownerImageServer ?? 1) : info.imgsvr;
const defaultSpecialWar =
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
const personality = resolveSelectedPersonality(worldState, seedOwnerIdentity, uniqueName, options.personality);
@@ -576,7 +582,7 @@ export const createGeneralFromSelectionPool = async (options: {
bornYear: worldState.currentYear - age,
deadYear: worldState.currentYear + 60,
picture,
imageServer: info.imgsvr,
imageServer,
stats: {
leadership: info.leadership,
strength: info.strength,
@@ -630,6 +636,9 @@ export const createGeneralFromSelectionPool = async (options: {
next_change: nextChangeAt.toISOString(),
nextChangeAt: nextChangeAt.toISOString(),
prestart_delete_after: prestartDeleteAfter.toISOString(),
...(useOwnerPicture && options.ownerIconRevision
? { accountIconUpdatedAt: options.ownerIconRevision }
: {}),
npc_org: 0,
},
};
@@ -326,6 +326,9 @@ async function handleSelectPoolCreate(
uniqueName: command.uniqueName,
personality: command.personality,
seedOwnerIdentity: command.seedOwnerIdentity,
...(command.ownerPicture ? { ownerPicture: command.ownerPicture } : {}),
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
...(command.ownerIconRevision ? { ownerIconRevision: command.ownerIconRevision } : {}),
now: acceptedAt,
})),
};
@@ -711,7 +714,7 @@ async function handleAdjustGeneralIcon(
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>
): Promise<TurnDaemonCommandResult> {
const db = requireCommandDatabase(ctx);
await resolveCommandAcceptedAt(db, command);
const acceptedAt = await resolveCommandAcceptedAt(db, command);
const general = ctx.world
.listGenerals()
.find((candidate) => candidate.userId === command.userId && candidate.npcState === 0);
@@ -724,6 +727,44 @@ async function handleAdjustGeneralIcon(
};
}
if (command.enforceCooldown) {
if (general.picture === command.picture && general.imageServer === command.imageServer) {
return {
type: 'adjustGeneralIcon',
ok: true,
generalId: general.id,
updated: false,
};
}
const changedAt = general.meta.generalIconChangedAt;
if (typeof changedAt === 'string' && isCanonicalIsoTimestamp(changedAt)) {
const availableAt = new Date(new Date(changedAt).getTime() + 24 * 60 * 60 * 1000);
if (availableAt.getTime() > acceptedAt.getTime()) {
return {
type: 'adjustGeneralIcon',
ok: false,
code: 'TOO_MANY_REQUESTS',
reason: `${availableAt.toISOString()}부터 전용 아이콘을 다시 바꿀 수 있습니다.`,
availableAt: availableAt.toISOString(),
};
}
}
ctx.world.updateGeneral(general.id, {
picture: command.picture,
imageServer: command.imageServer,
meta: {
...general.meta,
generalIconChangedAt: acceptedAt.toISOString(),
},
});
return {
type: 'adjustGeneralIcon',
ok: true,
generalId: general.id,
updated: true,
};
}
const currentRevision = general.meta.accountIconUpdatedAt;
if (currentRevision !== undefined) {
if (typeof currentRevision !== 'string' || !isCanonicalIsoTimestamp(currentRevision)) {