feat: add user icon library and in-game selection
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -88,11 +88,11 @@ const buildWorld = (generals: TurnGeneral[]) => {
|
||||
return new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||
};
|
||||
|
||||
const buildCommandDb = (actorUserId = 'user-1') =>
|
||||
const buildCommandDb = (actorUserId = 'user-1', acceptedAt = new Date('2026-07-31T09:00:00.000Z')) =>
|
||||
({
|
||||
inputEvent: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
createdAt: new Date('2026-07-31T09:00:00.000Z'),
|
||||
createdAt: acceptedAt,
|
||||
actorUserId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'adjustGeneralIcon',
|
||||
@@ -107,6 +107,7 @@ const command = (
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconRevision: string;
|
||||
enforceCooldown: boolean;
|
||||
}> = {}
|
||||
) => ({
|
||||
type: 'adjustGeneralIcon' as const,
|
||||
@@ -196,4 +197,26 @@ describe('adjustGeneralIcon ENGINE command', () => {
|
||||
);
|
||||
expect(actorWorld.getGeneralById(1)).toMatchObject({ picture: 'old.jpg', imageServer: 0 });
|
||||
});
|
||||
|
||||
it('allows human in-game changes only after a rolling 24-hour window', async () => {
|
||||
const changedAt = '2026-07-31T08:00:00.000Z';
|
||||
const world = buildWorld([buildGeneral({ meta: { killturn: 24, generalIconChangedAt: changedAt } })]);
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const manual = command({ enforceCooldown: true });
|
||||
|
||||
await expect(handler.handle(manual, { db: buildCommandDb() })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
availableAt: '2026-08-01T08:00:00.000Z',
|
||||
});
|
||||
expect(world.getGeneralById(1)).toMatchObject({ picture: 'old.jpg' });
|
||||
|
||||
await expect(
|
||||
handler.handle(manual, { db: buildCommandDb('user-1', new Date('2026-08-01T08:00:00.000Z')) })
|
||||
).resolves.toMatchObject({ ok: true, updated: true });
|
||||
expect(world.getGeneralById(1)).toMatchObject({
|
||||
picture: 'new.png',
|
||||
meta: { generalIconChangedAt: '2026-08-01T08:00:00.000Z' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user