merge: 최신 main을 외교 권한 복수 임명 수정에 다시 통합한다
This commit is contained in:
@@ -695,7 +695,13 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
export const generalRouter = router({
|
export const generalRouter = router({
|
||||||
adjustIcon: engineAuthedProcedure
|
adjustIcon: engineAuthedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({ iconId: z.string().uuid().optional(), clientRequestId: z.string().uuid().optional() }).optional()
|
z
|
||||||
|
.object({
|
||||||
|
iconId: z.string().uuid().optional(),
|
||||||
|
resetToDefault: z.literal(true).optional(),
|
||||||
|
clientRequestId: z.string().uuid().optional(),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
)
|
)
|
||||||
.mutation(({ ctx, input }) => {
|
.mutation(({ ctx, input }) => {
|
||||||
const userId = ctx.auth?.user.id;
|
const userId = ctx.auth?.user.id;
|
||||||
@@ -703,19 +709,41 @@ export const generalRouter = router({
|
|||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
const selected = input?.iconId ? ctx.auth?.user.icons?.find((icon) => icon.id === input.iconId) : undefined;
|
const selected = input?.iconId ? ctx.auth?.user.icons?.find((icon) => icon.id === input.iconId) : undefined;
|
||||||
if (input?.iconId && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) {
|
const resetToDefault = input?.resetToDefault === true;
|
||||||
|
if (resetToDefault && input?.iconId) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘 선택과 기본 아이콘 초기화를 함께 요청할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
if (!resetToDefault && !input?.iconId) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '적용할 활성 전용 아이콘을 선택해 주세요.' });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
resetToDefault &&
|
||||||
|
(ctx.auth?.user.picture !== 'default.jpg' || ctx.auth?.user.imageServer !== 0)
|
||||||
|
) {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: '현재 계정 아이콘이 기본 아이콘이 아닙니다.' });
|
||||||
|
}
|
||||||
|
if (!resetToDefault && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) {
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
|
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
|
||||||
}
|
}
|
||||||
|
const iconRevision = ctx.auth?.user.iconUpdatedAt ?? (resetToDefault ? undefined : selected!.createdAt);
|
||||||
|
if (!iconRevision) {
|
||||||
|
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '계정 아이콘 변경 시각을 확인할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
const projection = resetToDefault
|
||||||
|
? {
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
revision: iconRevision,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
picture: selected!.picture,
|
||||||
|
imageServer: selected!.imageServer,
|
||||||
|
revision: iconRevision,
|
||||||
|
};
|
||||||
return adjustAccountIconForUser(
|
return adjustAccountIconForUser(
|
||||||
ctx,
|
ctx,
|
||||||
userId,
|
userId,
|
||||||
selected
|
projection,
|
||||||
? {
|
|
||||||
picture: selected.picture,
|
|
||||||
imageServer: selected.imageServer,
|
|
||||||
revision: ctx.auth?.user.iconUpdatedAt ?? selected.createdAt,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
true,
|
true,
|
||||||
input?.clientRequestId ?? ctx.requestId
|
input?.clientRequestId ?? ctx.requestId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
WAR_TRAIT_KEYS,
|
WAR_TRAIT_KEYS,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
|
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
|
||||||
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
|
|
||||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||||
import { getSelectionPoolStatus, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
import { getSelectionPoolStatus, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
||||||
import {
|
import {
|
||||||
@@ -515,14 +514,16 @@ export const joinRouter = router({
|
|||||||
if (input.iconId && (!selectedIcon || auth.user.canUseGeneralPicture === false)) {
|
if (input.iconId && (!selectedIcon || auth.user.canUseGeneralPicture === false)) {
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
|
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
|
||||||
}
|
}
|
||||||
const accountIcon = input.pic
|
// 유저 장수에는 인증 token의 활성 전용 아이콘을 명시적으로 고른 경우만
|
||||||
? selectedIcon
|
// 그림을 적용한다. Gateway 대표 그림은 shared preset일 수 있으므로
|
||||||
|
// iconId 없는 fallback으로 사용하지 않는다.
|
||||||
|
const accountIcon =
|
||||||
|
input.pic && selectedIcon
|
||||||
? {
|
? {
|
||||||
picture: selectedIcon.picture,
|
picture: selectedIcon.picture,
|
||||||
imageServer: selectedIcon.imageServer,
|
imageServer: selectedIcon.imageServer,
|
||||||
revision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt,
|
revision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt,
|
||||||
}
|
}
|
||||||
: await loadAuthoritativeAccountIcon(ctx, userId)
|
|
||||||
: null;
|
: null;
|
||||||
const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
|
const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
|
||||||
const result = await requestJoinCreateCommand(ctx, {
|
const result = await requestJoinCreateCommand(ctx, {
|
||||||
@@ -535,7 +536,7 @@ export const joinRouter = router({
|
|||||||
leadership: input.leadership,
|
leadership: input.leadership,
|
||||||
strength: input.strength,
|
strength: input.strength,
|
||||||
intel: input.intel,
|
intel: input.intel,
|
||||||
pic: input.pic,
|
pic: accountIcon !== null,
|
||||||
character: input.character,
|
character: input.character,
|
||||||
profileId: ctx.profile.id,
|
profileId: ctx.profile.id,
|
||||||
...(accountIcon
|
...(accountIcon
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const loadAuthoritativeAccountIcon = async (
|
|||||||
export const adjustAccountIconForUser = async (
|
export const adjustAccountIconForUser = async (
|
||||||
ctx: GameApiContext,
|
ctx: GameApiContext,
|
||||||
userId: string,
|
userId: string,
|
||||||
selected?: AccountIconProjection,
|
selected: AccountIconProjection,
|
||||||
enforceCooldown = true,
|
enforceCooldown = true,
|
||||||
requestKey?: string
|
requestKey?: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -48,10 +48,8 @@ export const adjustAccountIconForUser = async (
|
|||||||
generalId: number | null;
|
generalId: number | null;
|
||||||
updated: boolean;
|
updated: boolean;
|
||||||
}> => {
|
}> => {
|
||||||
const projection = selected ?? (await loadAuthoritativeAccountIcon(ctx, userId));
|
const projection = selected;
|
||||||
const requestId = selected
|
const requestId = `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`;
|
||||||
? `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`
|
|
||||||
: `general:adjustIcon:${userId}:${projection.revision}`;
|
|
||||||
try {
|
try {
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralIcon',
|
type: 'adjustGeneralIcon',
|
||||||
|
|||||||
@@ -388,45 +388,56 @@ describe('appRouter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies the current Gateway database icon instead of stale token claims', async () => {
|
it('rejects icon adjustment without an explicitly selected active icon', async () => {
|
||||||
const transport = new InMemoryTurnDaemonTransport();
|
const transport = new InMemoryTurnDaemonTransport();
|
||||||
const currentAccountIcon = {
|
|
||||||
revision: '2026-07-31T09:00:00.000Z',
|
|
||||||
picture: 'latest.png',
|
|
||||||
imageServer: 1,
|
|
||||||
};
|
|
||||||
const auth = buildAuth();
|
const auth = buildAuth();
|
||||||
auth.user.picture = 'stale.png';
|
auth.user.picture = '장수/유비.jpg';
|
||||||
auth.user.imageServer = 0;
|
auth.user.imageServer = 0;
|
||||||
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
|
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
|
||||||
const requestId = `general:adjustIcon:${auth.user.id}:${currentAccountIcon.revision}`;
|
const accountIconGet = vi.fn(async () => ({
|
||||||
|
revision: '2026-07-31T09:00:00.000Z',
|
||||||
|
picture: '장수/유비.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
}));
|
||||||
|
const caller = appRouter.createCaller(
|
||||||
|
buildContext({
|
||||||
|
auth,
|
||||||
|
transport,
|
||||||
|
accountIconGet,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(caller.general.adjustIcon()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
|
await expect(caller.general.adjustIcon({ resetToDefault: true })).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
|
expect(accountIconGet).not.toHaveBeenCalled();
|
||||||
|
expect(transport.commands).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an explicit default reset only when the signed account projection is default', async () => {
|
||||||
|
const transport = new InMemoryTurnDaemonTransport();
|
||||||
|
const auth = buildAuth();
|
||||||
|
const revision = '2026-07-31T09:00:00.000Z';
|
||||||
|
auth.user.picture = 'default.jpg';
|
||||||
|
auth.user.imageServer = 0;
|
||||||
|
auth.user.iconUpdatedAt = revision;
|
||||||
|
const requestId = `general:adjustIcon:${auth.user.id}:manual:${revision}:default.jpg`;
|
||||||
transport.setCommandResult(requestId, {
|
transport.setCommandResult(requestId, {
|
||||||
type: 'adjustGeneralIcon',
|
type: 'adjustGeneralIcon',
|
||||||
ok: true,
|
ok: true,
|
||||||
generalId: 1,
|
generalId: 1,
|
||||||
updated: true,
|
updated: true,
|
||||||
});
|
});
|
||||||
const caller = appRouter.createCaller(
|
const caller = appRouter.createCaller(buildContext({ auth, transport }));
|
||||||
buildContext({
|
|
||||||
auth,
|
|
||||||
transport,
|
|
||||||
currentAccountIcon,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(caller.general.adjustIcon()).resolves.toEqual({
|
await expect(caller.general.adjustIcon({ resetToDefault: true })).resolves.toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
generalId: 1,
|
|
||||||
updated: true,
|
updated: true,
|
||||||
});
|
});
|
||||||
expect(transport.commands.at(-1)?.command).toEqual({
|
expect(transport.commands.at(-1)?.command).toMatchObject({
|
||||||
type: 'adjustGeneralIcon',
|
|
||||||
requestId,
|
requestId,
|
||||||
userId: auth.user.id,
|
picture: 'default.jpg',
|
||||||
picture: 'latest.png',
|
imageServer: 0,
|
||||||
imageServer: 1,
|
iconRevision: revision,
|
||||||
iconRevision: currentAccountIcon.revision,
|
|
||||||
enforceCooldown: true,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -461,13 +472,13 @@ describe('appRouter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects icon adjustment without auth or a current Gateway account', async () => {
|
it('rejects icon adjustment without auth or a selected icon', async () => {
|
||||||
await expect(appRouter.createCaller(buildContext({ auth: null })).general.adjustIcon()).rejects.toMatchObject({
|
await expect(appRouter.createCaller(buildContext({ auth: null })).general.adjustIcon()).rejects.toMatchObject({
|
||||||
code: 'UNAUTHORIZED',
|
code: 'UNAUTHORIZED',
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(buildContext({ auth: buildAuth() })).general.adjustIcon()
|
appRouter.createCaller(buildContext({ auth: buildAuth() })).general.adjustIcon()
|
||||||
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unauthenticated or game-blocked auth status checks', async () => {
|
it('rejects unauthenticated or game-blocked auth status checks', async () => {
|
||||||
@@ -581,30 +592,30 @@ describe('appRouter', () => {
|
|||||||
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision');
|
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the authoritative projection instead of stale token claims for picture creation', async () => {
|
it('does not apply a shared Gateway representative when no active icon id was selected', async () => {
|
||||||
const transport = new InMemoryTurnDaemonTransport();
|
const transport = new InMemoryTurnDaemonTransport();
|
||||||
const clientRequestId = '824454da-d0ab-48d2-a7d5-e2e5aaf83ba4';
|
const clientRequestId = '824454da-d0ab-48d2-a7d5-e2e5aaf83ba4';
|
||||||
const requestId = `join-create:user-1:${clientRequestId}`;
|
const requestId = `join-create:user-1:${clientRequestId}`;
|
||||||
const revision = '2026-07-31T09:00:00.001Z';
|
|
||||||
transport.setCommandResult(requestId, {
|
transport.setCommandResult(requestId, {
|
||||||
type: 'joinCreateGeneral',
|
type: 'joinCreateGeneral',
|
||||||
ok: true,
|
ok: true,
|
||||||
generalId: 42,
|
generalId: 42,
|
||||||
});
|
});
|
||||||
const auth = buildAuth();
|
const auth = buildAuth();
|
||||||
auth.user.picture = 'stale.png';
|
auth.user.picture = '장수/유비.jpg';
|
||||||
auth.user.imageServer = 0;
|
auth.user.imageServer = 0;
|
||||||
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
|
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
|
||||||
|
const accountIconGet = vi.fn(async () => ({
|
||||||
|
revision: '2026-07-31T09:00:00.001Z',
|
||||||
|
picture: '장수/유비.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
}));
|
||||||
const caller = appRouter.createCaller(
|
const caller = appRouter.createCaller(
|
||||||
buildContext({
|
buildContext({
|
||||||
state: buildWorldState(),
|
state: buildWorldState(),
|
||||||
auth,
|
auth,
|
||||||
transport,
|
transport,
|
||||||
currentAccountIcon: {
|
accountIconGet,
|
||||||
revision,
|
|
||||||
picture: 'latest.png',
|
|
||||||
imageServer: 1,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -618,11 +629,11 @@ describe('appRouter', () => {
|
|||||||
clientRequestId,
|
clientRequestId,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(transport.commands.at(-1)?.command).toMatchObject({
|
expect(accountIconGet).not.toHaveBeenCalled();
|
||||||
ownerPicture: 'latest.png',
|
expect(transport.commands.at(-1)?.command).toMatchObject({ pic: false });
|
||||||
ownerImageServer: 1,
|
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerPicture');
|
||||||
ownerIconRevision: revision,
|
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerImageServer');
|
||||||
});
|
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a general with the selected authenticated icon and rejects another icon id', async () => {
|
it('creates a general with the selected authenticated icon and rejects another icon id', async () => {
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
const initial = await db.general.findFirstOrThrow({ where: { userId } });
|
const initial = await db.general.findFirstOrThrow({ where: { userId } });
|
||||||
const initialRuntime = runtime!.world.getGeneralById(initial.id);
|
const initialRuntime = runtime!.world.getGeneralById(initial.id);
|
||||||
const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } });
|
const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } });
|
||||||
|
expect(initial).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
|
||||||
const acceptedEvent = await db.inputEvent.findFirstOrThrow({
|
const acceptedEvent = await db.inputEvent.findFirstOrThrow({
|
||||||
where: { actorUserId: userId, eventType: 'selectPoolCreate', status: 'SUCCEEDED' },
|
where: { actorUserId: userId, eventType: 'selectPoolCreate', status: 'SUCCEEDED' },
|
||||||
orderBy: { sequence: 'desc' },
|
orderBy: { sequence: 'desc' },
|
||||||
@@ -273,7 +274,8 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
id: initial.id,
|
id: initial.id,
|
||||||
userId,
|
userId,
|
||||||
name: initial.name,
|
name: initial.name,
|
||||||
imageServer: initial.imageServer,
|
imageServer: 0,
|
||||||
|
picture: 'default.jpg',
|
||||||
stats: {
|
stats: {
|
||||||
leadership: initial.leadership,
|
leadership: initial.leadership,
|
||||||
strength: initial.strength,
|
strength: initial.strength,
|
||||||
@@ -385,15 +387,15 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
intel: target.intel,
|
intel: target.intel,
|
||||||
personalCode: initial.personalCode,
|
personalCode: initial.personalCode,
|
||||||
specialCode: target.specialDomestic,
|
specialCode: target.specialDomestic,
|
||||||
imageServer: target.imageServer,
|
imageServer: 0,
|
||||||
picture: target.picture,
|
picture: 'default.jpg',
|
||||||
});
|
});
|
||||||
expect(runtime!.world.getGeneralById(initial.id)).toMatchObject({
|
expect(runtime!.world.getGeneralById(initial.id)).toMatchObject({
|
||||||
id: initial.id,
|
id: initial.id,
|
||||||
userId,
|
userId,
|
||||||
name: target.generalName,
|
name: target.generalName,
|
||||||
imageServer: target.imageServer,
|
imageServer: 0,
|
||||||
picture: target.picture,
|
picture: 'default.jpg',
|
||||||
stats: {
|
stats: {
|
||||||
leadership: target.leadership,
|
leadership: target.leadership,
|
||||||
strength: target.strength,
|
strength: target.strength,
|
||||||
|
|||||||
@@ -174,7 +174,10 @@ const processIncomeForNation = (
|
|||||||
world.updateNation(nation.id, { rice: next, meta: nextMeta });
|
world.updateNation(nation.id, { rice: next, meta: nextMeta });
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomeText = incomeValue.toLocaleString();
|
// Ref keeps the fractional pre-flush value for the payout ratio and
|
||||||
|
// prev_income_* metadata, but number_format() rounds the user-facing log
|
||||||
|
// to the same integer precision as the persisted resource column.
|
||||||
|
const incomeText = Math.round(incomeValue).toLocaleString('en-US');
|
||||||
const incomeLog =
|
const incomeLog =
|
||||||
type === 'gold' ? `이번 수입은 금 <C>${incomeText}</>입니다.` : `이번 수입은 쌀 <C>${incomeText}</>입니다.`;
|
type === 'gold' ? `이번 수입은 금 <C>${incomeText}</>입니다.` : `이번 수입은 쌀 <C>${incomeText}</>입니다.`;
|
||||||
for (const general of nationGenerals) {
|
for (const general of nationGenerals) {
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ const DEFAULT_CREW_TYPE_ID = 1100;
|
|||||||
const MAX_GENERAL_TURNS = 30;
|
const MAX_GENERAL_TURNS = 30;
|
||||||
const DEFAULT_TURN_ACTION = '휴식';
|
const DEFAULT_TURN_ACTION = '휴식';
|
||||||
|
|
||||||
|
export const resolveSelectionPoolUserIcon = (options: {
|
||||||
|
showImgLevel: number;
|
||||||
|
ownerPicture?: string;
|
||||||
|
ownerImageServer?: number;
|
||||||
|
}): { picture: string; imageServer: number } => {
|
||||||
|
const useOwnerPicture =
|
||||||
|
options.showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg';
|
||||||
|
return useOwnerPicture
|
||||||
|
? { picture: options.ownerPicture!, imageServer: options.ownerImageServer ?? 1 }
|
||||||
|
: { picture: 'default.jpg', imageServer: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
const zCandidateInfo = z.object({
|
const zCandidateInfo = z.object({
|
||||||
uniqueName: z.string().min(1),
|
uniqueName: z.string().min(1),
|
||||||
generalName: z.string().min(1),
|
generalName: z.string().min(1),
|
||||||
@@ -756,11 +768,14 @@ export const createGeneralFromSelectionPool = async (options: {
|
|||||||
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000
|
||||||
);
|
);
|
||||||
const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config);
|
const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config);
|
||||||
const showImgLevel = asNumber(config.showImgLevel, 0);
|
// 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는
|
||||||
const useOwnerPicture =
|
// 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다.
|
||||||
showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg';
|
const { picture, imageServer } = resolveSelectionPoolUserIcon({
|
||||||
const picture = useOwnerPicture ? options.ownerPicture! : showImgLevel >= 3 ? info.picture : 'default.jpg';
|
showImgLevel: asNumber(config.showImgLevel, 0),
|
||||||
const imageServer = useOwnerPicture ? (options.ownerImageServer ?? 1) : info.imgsvr;
|
ownerPicture: options.ownerPicture,
|
||||||
|
ownerImageServer: options.ownerImageServer,
|
||||||
|
});
|
||||||
|
const useOwnerPicture = picture !== 'default.jpg';
|
||||||
const defaultSpecialWar =
|
const defaultSpecialWar =
|
||||||
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
||||||
const defaultSpecialDomestic =
|
const defaultSpecialDomestic =
|
||||||
@@ -1023,6 +1038,7 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
|||||||
now
|
now
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
const reselectionIcon = resolveSelectionPoolUserIcon({ showImgLevel: 0 });
|
||||||
const updated = world.updateGeneral(general.id, {
|
const updated = world.updateGeneral(general.id, {
|
||||||
name: info.generalName,
|
name: info.generalName,
|
||||||
stats: centennialGrowth?.stats ?? {
|
stats: centennialGrowth?.stats ?? {
|
||||||
@@ -1036,8 +1052,10 @@ export const reselectGeneralFromSelectionPool = async (options: {
|
|||||||
specialDomestic: info.specialDomestic,
|
specialDomestic: info.specialDomestic,
|
||||||
specialWar: info.specialWar ?? general.role.specialWar,
|
specialWar: info.specialWar ?? general.role.specialWar,
|
||||||
},
|
},
|
||||||
picture: info.picture,
|
// 재선택 후보의 preset은 유저 장수에 이어 붙이지 않는다. 전용 아이콘을
|
||||||
imageServer: info.imgsvr,
|
// 다시 고르는 UI가 없는 현재 경로는 안전한 기본 아이콘으로 되돌린다.
|
||||||
|
picture: reselectionIcon.picture,
|
||||||
|
imageServer: reselectionIcon.imageServer,
|
||||||
meta: updatedMeta,
|
meta: updatedMeta,
|
||||||
});
|
});
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
|||||||
@@ -373,6 +373,13 @@ describe('core monthly event actions at the real month boundary', () => {
|
|||||||
await world.advanceMonth(new Date('0191-01-01T00:00:00.000Z'));
|
await world.advanceMonth(new Date('0191-01-01T00:00:00.000Z'));
|
||||||
|
|
||||||
expect(world.getNationById(1)?.meta.prev_income_gold).toBe(157.5);
|
expect(world.getNationById(1)?.meta.prev_income_gold).toBe(157.5);
|
||||||
|
expect(world.peekDirtyState().logs).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
generalId: 1,
|
||||||
|
text: '이번 수입은 금 <C>158</>입니다.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(world.peekDirtyState().logs.map((entry) => entry.text).join('\n')).not.toContain('157.5');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the Ref default nation resource floors when scenario const omits them', async () => {
|
it('uses the Ref default nation resource floors when scenario const omits them', async () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
|||||||
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
|
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
import { reserveSelectionPool } from '../src/turn/selectPoolService.js';
|
import { reserveSelectionPool, resolveSelectionPoolUserIcon } from '../src/turn/selectPoolService.js';
|
||||||
import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
|
||||||
interface TestPoolRow {
|
interface TestPoolRow {
|
||||||
@@ -171,6 +171,27 @@ const worldState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('selection-pool reservation command state', () => {
|
describe('selection-pool reservation command state', () => {
|
||||||
|
it('uses only an explicitly selected owner icon for a human general', () => {
|
||||||
|
expect(resolveSelectionPoolUserIcon({ showImgLevel: 3 })).toEqual({
|
||||||
|
picture: 'default.jpg',
|
||||||
|
imageServer: 0,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveSelectionPoolUserIcon({
|
||||||
|
showImgLevel: 3,
|
||||||
|
ownerPicture: 'uploaded/user.png',
|
||||||
|
ownerImageServer: 1,
|
||||||
|
})
|
||||||
|
).toEqual({ picture: 'uploaded/user.png', imageServer: 1 });
|
||||||
|
expect(
|
||||||
|
resolveSelectionPoolUserIcon({
|
||||||
|
showImgLevel: 0,
|
||||||
|
ownerPicture: 'uploaded/user.png',
|
||||||
|
ownerImageServer: 1,
|
||||||
|
})
|
||||||
|
).toEqual({ picture: 'default.jpg', imageServer: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
it('excludes current reservations and keeps serialized users disjoint in DB and memory', async () => {
|
it('excludes current reservations and keeps serialized users disjoint in DB and memory', async () => {
|
||||||
const rows = buildRows();
|
const rows = buildRows();
|
||||||
const world = buildWorld(rows);
|
const world = buildWorld(rows);
|
||||||
|
|||||||
@@ -1990,6 +1990,40 @@ test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다'
|
|||||||
expect(state.createGeneralInputs?.[0]).toMatchObject({ pic: true, iconId: secondIconId });
|
expect(state.createGeneralInputs?.[0]).toMatchObject({ pic: true, iconId: secondIconId });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('활성 전용 아이콘이 없으면 대표 preset을 장수 생성 요청에 전달하지 않는다', async ({ page }) => {
|
||||||
|
const state: FixtureState = {
|
||||||
|
permission: 'member',
|
||||||
|
myset: 1,
|
||||||
|
settingMutations: [],
|
||||||
|
accessPages: [],
|
||||||
|
createGeneralInputs: [],
|
||||||
|
joinConfig: {
|
||||||
|
rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true },
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
displayName: '생성장수',
|
||||||
|
canCreateGeneral: true,
|
||||||
|
preferredPicture: '장수/유비.jpg',
|
||||||
|
icons: [],
|
||||||
|
},
|
||||||
|
personalities: [{ key: 'Random', name: '???', info: '무작위 성격' }],
|
||||||
|
nations: [],
|
||||||
|
selectionPool: { enabled: false },
|
||||||
|
npcPossession: { enabled: false },
|
||||||
|
inherit: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await install(page, state);
|
||||||
|
await page.goto('join');
|
||||||
|
|
||||||
|
await expect(page.getByRole('radiogroup', { name: '전용 아이콘 선택' })).toHaveCount(0);
|
||||||
|
await page.getByRole('button', { name: '장수 생성', exact: true }).last().click();
|
||||||
|
|
||||||
|
await expect.poll(() => state.createGeneralInputs?.length ?? 0).toBe(1);
|
||||||
|
expect(state.createGeneralInputs?.[0]).toMatchObject({ pic: false });
|
||||||
|
expect(state.createGeneralInputs?.[0]).not.toHaveProperty('iconId');
|
||||||
|
});
|
||||||
|
|
||||||
test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 새 ID를 만든다', async ({ page }) => {
|
test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 새 ID를 만든다', async ({ page }) => {
|
||||||
const state: FixtureState = {
|
const state: FixtureState = {
|
||||||
permission: 'head',
|
permission: 'head',
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const history = [
|
|||||||
'<span class="war_type war_type_attack">→</span><span class="ev_highlight">강조</span>' +
|
'<span class="war_type war_type_attack">→</span><span class="ev_highlight">강조</span>' +
|
||||||
'<span class="name" onclick="globalThis.__legacyLogXss=3">오염 이름</span></div>',
|
'<span class="name" onclick="globalThis.__legacyLogXss=3">오염 이름</span></div>',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
text: '이번 수입은 금 <C>158</>입니다.',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const publicResponse = (operation: string): unknown => {
|
const publicResponse = (operation: string): unknown => {
|
||||||
@@ -59,10 +63,12 @@ for (const viewport of [
|
|||||||
await page.goto('public');
|
await page.goto('public');
|
||||||
|
|
||||||
const lines = page.locator('.recent-log-line');
|
const lines = page.locator('.recent-log-line');
|
||||||
await expect(lines).toHaveCount(2);
|
await expect(lines).toHaveCount(3);
|
||||||
await expect(lines.nth(0).locator('b')).toHaveText('안전 강조');
|
await expect(lines.nth(0).locator('b')).toHaveText('안전 강조');
|
||||||
await expect(lines.nth(1).locator('.small_war_log .war_type_attack')).toHaveText('→');
|
await expect(lines.nth(1).locator('.small_war_log .war_type_attack')).toHaveText('→');
|
||||||
await expect(lines.nth(1).locator('.ev_highlight')).toHaveText('강조');
|
await expect(lines.nth(1).locator('.ev_highlight')).toHaveText('강조');
|
||||||
|
await expect(lines.nth(2)).toHaveText('이번 수입은 금 158입니다.');
|
||||||
|
await expect(lines.nth(2)).not.toContainText('157.5');
|
||||||
await expect(lines.locator('script, img, svg, a, [onerror], [onclick], [style*="url"]')).toHaveCount(0);
|
await expect(lines.locator('script, img, svg, a, [onerror], [onclick], [style*="url"]')).toHaveCount(0);
|
||||||
await expect(lines.nth(0)).toContainText('<img src=x onerror=');
|
await expect(lines.nth(0)).toContainText('<img src=x onerror=');
|
||||||
await expect(lines.nth(1)).toContainText('<span class="name" onclick=');
|
await expect(lines.nth(1)).toContainText('<span class="name" onclick=');
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ test.describe('scenario 903 live selection pool', () => {
|
|||||||
expect(created.name).toBe(initialName?.trim());
|
expect(created.name).toBe(initialName?.trim());
|
||||||
expect(created.personalCode).toBe('che_안전');
|
expect(created.personalCode).toBe('che_안전');
|
||||||
expect(created.specialCode).toMatch(/^che_event_/);
|
expect(created.specialCode).toMatch(/^che_event_/);
|
||||||
|
expect(created).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
|
||||||
const createEvent = await db.inputEvent.findFirstOrThrow({
|
const createEvent = await db.inputEvent.findFirstOrThrow({
|
||||||
where: { actorUserId: userId, eventType: 'selectPoolCreate' },
|
where: { actorUserId: userId, eventType: 'selectPoolCreate' },
|
||||||
orderBy: { sequence: 'desc' },
|
orderBy: { sequence: 'desc' },
|
||||||
@@ -508,6 +509,12 @@ test.describe('scenario 903 live selection pool', () => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(async () => (await db.general.findUniqueOrThrow({ where: { id: created.id } })).name)
|
.poll(async () => (await db.general.findUniqueOrThrow({ where: { id: created.id } })).name)
|
||||||
.toBe(targetName);
|
.toBe(targetName);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const general = await db.general.findUniqueOrThrow({ where: { id: created.id } });
|
||||||
|
return { picture: general.picture, imageServer: general.imageServer };
|
||||||
|
})
|
||||||
|
.toEqual({ picture: 'default.jpg', imageServer: 0 });
|
||||||
const reselectEvent = await db.inputEvent.findFirstOrThrow({
|
const reselectEvent = await db.inputEvent.findFirstOrThrow({
|
||||||
where: { actorUserId: userId, eventType: 'selectPoolReselect' },
|
where: { actorUserId: userId, eventType: 'selectPoolReselect' },
|
||||||
orderBy: { sequence: 'desc' },
|
orderBy: { sequence: 'desc' },
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const form = ref<JoinForm>({
|
|||||||
strength: 0,
|
strength: 0,
|
||||||
intel: 0,
|
intel: 0,
|
||||||
character: 'Random',
|
character: 'Random',
|
||||||
pic: true,
|
pic: false,
|
||||||
iconId: undefined,
|
iconId: undefined,
|
||||||
inheritBonusStat: [0, 0, 0],
|
inheritBonusStat: [0, 0, 0],
|
||||||
});
|
});
|
||||||
@@ -437,6 +437,7 @@ const loadConfig = async () => {
|
|||||||
} else {
|
} else {
|
||||||
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위';
|
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위';
|
||||||
form.value.iconId = config.user.icons.find((icon) => icon.picture === config.user.preferredPicture)?.id;
|
form.value.iconId = config.user.icons.find((icon) => icon.picture === config.user.preferredPicture)?.id;
|
||||||
|
form.value.pic = form.value.iconId !== undefined;
|
||||||
applyBalancedStats();
|
applyBalancedStats();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ onBeforeUnmount(() => {
|
|||||||
<th class="legacy-bg1">전콘 선택</th>
|
<th class="legacy-bg1">전콘 선택</th>
|
||||||
<td class="pool-icon-choice">
|
<td class="pool-icon-choice">
|
||||||
<label>
|
<label>
|
||||||
<input v-model="selectedIconId" type="radio" value="" /> 선택한 장수 전콘
|
<input v-model="selectedIconId" type="radio" value="" /> 기본 아이콘
|
||||||
</label>
|
</label>
|
||||||
<label v-for="icon in config.user.icons" :key="icon.id">
|
<label v-for="icon in config.user.icons" :key="icon.id">
|
||||||
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ const profileInputAt = (body: string, index: number): string | null => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const adjustIconInputAt = (body: string, index: number): Record<string, unknown> => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body) as Record<string, Record<string, unknown> & { json?: Record<string, unknown> }>;
|
||||||
|
const input = parsed[String(index)];
|
||||||
|
return input?.json ?? input ?? {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
type FixtureOptions = {
|
type FixtureOptions = {
|
||||||
failHweAdjustOnce?: boolean;
|
failHweAdjustOnce?: boolean;
|
||||||
delayHweAdjust?: boolean;
|
delayHweAdjust?: boolean;
|
||||||
@@ -65,6 +75,7 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
|||||||
let preferredIconCount = 0;
|
let preferredIconCount = 0;
|
||||||
let retireIconCount = 0;
|
let retireIconCount = 0;
|
||||||
let hweAdjustCount = 0;
|
let hweAdjustCount = 0;
|
||||||
|
const adjustIconInputs: Array<Record<string, unknown>> = [];
|
||||||
const operations = new Map<string, string[]>([
|
const operations = new Map<string, string[]>([
|
||||||
['che:903', []],
|
['che:903', []],
|
||||||
['hwe:903', []],
|
['hwe:903', []],
|
||||||
@@ -199,7 +210,8 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
|||||||
`/${profileName.split(':')[0]}/api/trpc/${operationNames(route).join(',')}`
|
`/${profileName.split(':')[0]}/api/trpc/${operationNames(route).join(',')}`
|
||||||
);
|
);
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const operation of operationNames(route)) {
|
const body = route.request().postData() ?? '';
|
||||||
|
for (const [index, operation] of operationNames(route).entries()) {
|
||||||
if (operation === 'auth.exchangeGatewayToken') {
|
if (operation === 'auth.exchangeGatewayToken') {
|
||||||
operations.get(profileName)?.push('exchangeGatewayToken');
|
operations.get(profileName)?.push('exchangeGatewayToken');
|
||||||
results.push(
|
results.push(
|
||||||
@@ -213,6 +225,11 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
|||||||
}
|
}
|
||||||
if (operation === 'general.adjustIcon') {
|
if (operation === 'general.adjustIcon') {
|
||||||
operations.get(profileName)?.push('adjustIcon');
|
operations.get(profileName)?.push('adjustIcon');
|
||||||
|
const input = adjustIconInputAt(body, index);
|
||||||
|
adjustIconInputs.push(input);
|
||||||
|
if (input.resetToDefault !== true) {
|
||||||
|
expect(input.iconId).toBe('3f804277-584f-4f44-b39c-9ecf40d1ed31');
|
||||||
|
}
|
||||||
if (profileName === 'hwe:903') {
|
if (profileName === 'hwe:903') {
|
||||||
hweAdjustCount += 1;
|
hweAdjustCount += 1;
|
||||||
if (options.delayHweAdjust) {
|
if (options.delayHweAdjust) {
|
||||||
@@ -242,6 +259,7 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
|||||||
deleteIconCount: () => deleteIconCount,
|
deleteIconCount: () => deleteIconCount,
|
||||||
preferredIconCount: () => preferredIconCount,
|
preferredIconCount: () => preferredIconCount,
|
||||||
retireIconCount: () => retireIconCount,
|
retireIconCount: () => retireIconCount,
|
||||||
|
adjustIconInputs: () => adjustIconInputs,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -558,6 +576,9 @@ test('uses the Ref delete confirmation and opens the modal only after acceptance
|
|||||||
.toBe('none');
|
.toBe('none');
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
|
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
|
||||||
|
await apply.click();
|
||||||
|
await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('적용됨');
|
||||||
|
expect(fixture.adjustIconInputs().at(-1)).toEqual({ resetToDefault: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('contains focus and long failure content inside a 320px viewport', async ({ page }) => {
|
test('contains focus and long failure content inside a 320px viewport', async ({ page }) => {
|
||||||
|
|||||||
@@ -442,7 +442,16 @@ const syncIconToServer = async (row: IconSyncRow, token: string): Promise<void>
|
|||||||
gatewayToken: issued.gameToken,
|
gatewayToken: issued.gameToken,
|
||||||
});
|
});
|
||||||
const gameTrpc = createGameTrpc(row.profile, row.apiPort, exchanged.accessToken);
|
const gameTrpc = createGameTrpc(row.profile, row.apiPort, exchanged.accessToken);
|
||||||
await gameTrpc.general.adjustIcon.mutate();
|
if (!account.value?.iconUrl) {
|
||||||
|
await gameTrpc.general.adjustIcon.mutate({ resetToDefault: true });
|
||||||
|
row.state = 'success';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedIconId = account.value?.icons.find(
|
||||||
|
(icon) => icon.picture === account.value?.preferredPicture
|
||||||
|
)?.id;
|
||||||
|
if (!selectedIconId) throw new Error('적용할 활성 전용 아이콘을 찾을 수 없습니다.');
|
||||||
|
await gameTrpc.general.adjustIcon.mutate({ iconId: selectedIconId });
|
||||||
row.state = 'success';
|
row.state = 'success';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
row.state = 'error';
|
row.state = 'error';
|
||||||
|
|||||||
Reference in New Issue
Block a user