feat: synchronize account icons across game profiles

This commit is contained in:
2026-07-31 11:21:08 +00:00
parent c8adeeb47b
commit 5f20413552
87 changed files with 5755 additions and 280 deletions
@@ -1,6 +1,7 @@
import { z } from 'zod';
import type { TurnDaemonCommand, TurnDaemonCommandType, TurnDaemonCommandByType } from '@sammo-ts/common';
import { isCanonicalIsoTimestamp } from '@sammo-ts/common';
export type TurnDaemonCommandEnvelope = {
requestId: string;
@@ -252,6 +253,17 @@ const zPatchGeneral = z.object({
}),
});
const zAdjustGeneralIcon = z
.object({
type: z.literal('adjustGeneralIcon'),
requestId: z.string().min(1).optional(),
userId: z.string().min(1),
picture: z.string().min(1),
imageServer: z.number().int().nonnegative(),
iconRevision: z.string().refine(isCanonicalIsoTimestamp),
})
.strict();
const zJoinCreateGeneral = z
.object({
type: z.literal('joinCreateGeneral'),
@@ -268,6 +280,7 @@ const zJoinCreateGeneral = z
profileId: z.string().min(1),
ownerPicture: z.string().optional(),
ownerImageServer: zFiniteNumber.optional(),
ownerIconRevision: z.string().refine(isCanonicalIsoTimestamp).optional(),
ownerCanUsePicture: z.boolean().optional(),
ownerLegacyPenalty: zRecord.optional(),
inheritSpecial: z.string().optional(),
@@ -566,6 +579,14 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => {
return { ...command, requestId: envelope.requestId };
};
const normalizeAdjustGeneralIcon: CommandNormalizer<'adjustGeneralIcon'> = (envelope) => {
const command = parseWith(zAdjustGeneralIcon, envelope.command);
if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) {
return null;
}
return { ...command, requestId: envelope.requestId };
};
const normalizeJoinCreateGeneral: CommandNormalizer<'joinCreateGeneral'> = (envelope) => {
const command = parseWith(zJoinCreateGeneral, envelope.command);
if (!command) {
@@ -662,6 +683,7 @@ const normalizers: CommandNormalizerMap = {
adjustGeneralMeta: normalizeAdjustGeneralMeta,
tournamentMatchResult: normalizeTournamentMatchResult,
patchGeneral: normalizePatchGeneral,
adjustGeneralIcon: normalizeAdjustGeneralIcon,
joinCreateGeneral: normalizeJoinCreateGeneral,
npcPossessGeneral: normalizeNpcPossessGeneral,
selectPoolCreate: normalizeSelectPoolCreate,
@@ -51,6 +51,7 @@ export interface JoinCreateGeneralInput {
profileId: string;
ownerPicture?: string;
ownerImageServer?: number;
ownerIconRevision?: string;
ownerCanUsePicture?: boolean;
ownerLegacyPenalty?: Record<string, unknown>;
inheritSpecial?: string;
@@ -700,6 +701,12 @@ export const createGeneralFromJoin = async (options: {
input.ownerPicture !== 'default.jpg';
const picture = canUseOwnerPicture ? input.ownerPicture! : 'default.jpg';
const imageServer = canUseOwnerPicture ? Math.max(0, Math.floor(input.ownerImageServer ?? 0)) : 0;
const accountIconUpdatedAt =
input.ownerIconRevision &&
picture === input.ownerPicture &&
imageServer === Math.max(0, Math.floor(input.ownerImageServer ?? 0))
? input.ownerIconRevision
: undefined;
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId);
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
@@ -781,6 +788,7 @@ export const createGeneralFromJoin = async (options: {
newvote: 0,
inherit_spent_dyn: inheritRequiredPoint,
prestart_delete_after: prestartDeleteAfter.toISOString(),
...(accountIconUpdatedAt ? { accountIconUpdatedAt } : {}),
},
};
if (!world.addGeneral(general)) {
@@ -5,7 +5,7 @@ import type {
TurnDaemonCommandResult,
} from '../lifecycle/types.js';
import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra';
import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { asRecord, isCanonicalIsoTimestamp, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import {
LogCategory,
LogFormat,
@@ -133,7 +133,7 @@ interface CommandHandlerContext {
const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
if (!ctx.commandDb) {
throw new Error('ENGINE mutation transaction is required for selection-pool commands.');
throw new Error('ENGINE mutation transaction is required for authenticated commands.');
}
return ctx.commandDb as unknown as DatabaseClient;
};
@@ -149,7 +149,8 @@ const resolveCommandAcceptedAt = async (
| 'joinCreateGeneral'
| 'npcPossessGeneral'
| 'selectPoolCreate'
| 'selectPoolReselect';
| 'selectPoolReselect'
| 'adjustGeneralIcon';
}
>
): Promise<Date> => {
@@ -228,6 +229,9 @@ async function handleJoinCreateGeneral(
profileId: command.profileId,
...(command.ownerPicture !== undefined ? { ownerPicture: command.ownerPicture } : {}),
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
...(command.ownerIconRevision !== undefined
? { ownerIconRevision: command.ownerIconRevision }
: {}),
...(command.ownerCanUsePicture !== undefined
? { ownerCanUsePicture: command.ownerCanUsePicture }
: {}),
@@ -702,6 +706,78 @@ async function handlePatchGeneral(
return { type: 'patchGeneral', ok: true, generalId: command.generalId };
}
async function handleAdjustGeneralIcon(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>
): Promise<TurnDaemonCommandResult> {
const db = requireCommandDatabase(ctx);
await resolveCommandAcceptedAt(db, command);
const general = ctx.world
.listGenerals()
.find((candidate) => candidate.userId === command.userId && candidate.npcState === 0);
if (!general) {
return {
type: 'adjustGeneralIcon',
ok: true,
generalId: null,
updated: false,
};
}
const currentRevision = general.meta.accountIconUpdatedAt;
if (currentRevision !== undefined) {
if (typeof currentRevision !== 'string' || !isCanonicalIsoTimestamp(currentRevision)) {
return {
type: 'adjustGeneralIcon',
ok: false,
code: 'PRECONDITION_FAILED',
reason: '장수의 계정 아이콘 revision이 올바르지 않습니다.',
};
}
const currentRevisionTime = new Date(currentRevision).getTime();
const nextRevisionTime = new Date(command.iconRevision).getTime();
if (nextRevisionTime < currentRevisionTime) {
return {
type: 'adjustGeneralIcon',
ok: false,
code: 'CONFLICT',
reason: '더 최신 계정 아이콘이 이미 적용되었습니다.',
};
}
if (nextRevisionTime === currentRevisionTime) {
if (general.picture === command.picture && general.imageServer === command.imageServer) {
return {
type: 'adjustGeneralIcon',
ok: true,
generalId: general.id,
updated: false,
};
}
return {
type: 'adjustGeneralIcon',
ok: false,
code: 'CONFLICT',
reason: '같은 revision에 다른 계정 아이콘이 요청되었습니다.',
};
}
}
ctx.world.updateGeneral(general.id, {
picture: command.picture,
imageServer: command.imageServer,
meta: {
...general.meta,
accountIconUpdatedAt: command.iconRevision,
},
});
return {
type: 'adjustGeneralIcon',
ok: true,
generalId: general.id,
updated: true,
};
}
async function handleShiftSchedule(
ctx: CommandHandlerContext,
command: Extract<TurnDaemonCommand, { type: 'shiftSchedule' }>
@@ -2296,6 +2372,8 @@ export const createTurnDaemonCommandHandler = (options: {
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
patchGeneral: (command) =>
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
adjustGeneralIcon: (command) =>
handleAdjustGeneralIcon(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>),
joinCreateGeneral: (command) =>
handleJoinCreateGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>),
npcPossessGeneral: (command) =>