fix: 은퇴 명예 기록과 유산 수명주기 정합성을 복원한다

은퇴·사망·통일의 저장 순서와 명예의 전당 및 명장일람 판정을 Ref 흐름에 맞춘다.

유산 행동을 인증된 daemon transaction으로 통합하고 중복 지급·고유 아이템·로그·오류 경계를 회귀 테스트한다.
This commit is contained in:
2026-08-24 03:38:12 +00:00
parent 95cf68dffd
commit 4fc20de8d4
33 changed files with 4160 additions and 1166 deletions
@@ -1,68 +1,258 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { SystemClock } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
import {
createDatabaseTurnHooks,
DatabaseTurnDaemonCommandQueue,
EngineStateManager,
InMemoryTurnStateStore,
InMemoryTurnWorld,
loadTurnWorldFromDatabase,
TurnDaemonLifecycle,
type TurnGeneral,
type TurnWorldSnapshot,
type TurnWorldState,
} from '@sammo-ts/game-engine';
import { createTurnDaemonCommandHandler } from '@sammo-ts/game-engine/turn/worldCommandHandler.js';
import {
createGamePostgresConnector,
type GamePrisma,
type GamePrismaClient,
type RedisConnector,
} from '@sammo-ts/infra';
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { GameApiContext } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { appRouter } from '../src/router.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const actorGeneralId = 8_701;
const checkedGeneralId = 8_702;
const actorNationId = 871;
const checkedNationId = 872;
const worldId = 992_320;
const actorId = 7_320;
const targetId = 7_321;
const actorNationId = 7_322;
const targetNationId = 7_323;
const actorUserId = 'inherit-owner-message-actor';
const checkedUserId = 'inherit-owner-message-checked';
const targetUserId = 'inherit-owner-message-target';
const requestId = 'integration:inherit-owner-message:success';
const engineRequestId = `${requestId}:inherit.checkOwner:engine:0:inheritanceAction`;
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const scenarioConfig: ScenarioConfig = {
stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: { inheritCheckOwnerPoint: 1_000 },
environment: { mapName: 'che', unitSet: 'che' },
};
const scenarioMeta: ScenarioMeta = {
title: '소유자 확인 통합',
startYear: 200,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
};
const map: MapDefinition = { id: 'inherit-owner-message', name: scenarioMeta.title, cities: [] };
const state: TurnWorldState = {
id: worldId,
currentYear: 200,
currentMonth: 4,
tickSeconds: 600,
lastTurnTime: new Date('2026-08-19T00:00:00.000Z'),
meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta },
};
const general = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
id: actorId,
userId: actorUserId,
name: '확인장수',
nationId: actorNationId,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 45, intelligence: 85 },
turnTime: new Date('2026-08-19T00:10:00.000Z'),
recentWarTime: null,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, inherit_spent_dyn: 0 },
inheritancePoints: { previous: 1_500 },
penalty: {},
officerLevel: 1,
experience: 0,
dedication: 0,
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
...overrides,
});
const actor = general({});
const target = general({
id: targetId,
userId: targetUserId,
name: '피확인장수',
nationId: targetNationId,
meta: { killturn: 24, owner_name: '피확인 계정' },
inheritancePoints: { previous: 0 },
});
const nation = (id: number, name: string, chiefGeneralId: number) => ({
id,
name,
color: id === actorNationId ? '#123456' : '#654321',
capitalCityId: null,
chiefGeneralId,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
});
const actorNation = nation(actorNationId, '확인국', actorId);
const targetNation = nation(targetNationId, '피확인국', targetId);
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:inherit-owner-message',
issuedAt: '2026-08-19T00:00:00.000Z',
expiresAt: '2026-08-20T00:00:00.000Z',
sessionId: 'inherit-owner-message-session',
user: {
id: actorUserId,
username: actorUserId,
displayName: '확인자 계정',
roles: ['user'],
},
user: { id: actorUserId, username: actorUserId, displayName: '확인자 계정', roles: ['user'] },
sanctions: {},
};
const hasMailboxChange = (payload: unknown): boolean => {
if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false;
const changes = (payload as { changes?: unknown }).changes;
if (!Array.isArray(changes)) return false;
const mailboxes = new Set([actorGeneralId, checkedGeneralId]);
return changes.some(
(change) =>
Array.isArray(change) &&
change[0] === 'messages.mailbox' &&
typeof change[1] === 'number' &&
mailboxes.has(change[1])
);
};
const toCreate = (entry: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({
id: entry.id,
userId: entry.userId,
name: entry.name,
nationId: entry.nationId,
cityId: entry.cityId,
npcState: entry.npcState,
leadership: entry.stats.leadership,
strength: entry.stats.strength,
intel: entry.stats.intelligence,
turnTime: entry.turnTime,
meta: entry.meta as GamePrisma.InputJsonValue,
penalty: entry.penalty as GamePrisma.InputJsonValue,
});
integration('inherit owner lookup private messages', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let worldStateId: number;
const buildContext = (requestId: string): GameApiContext => {
const redisClient = {
get: async () => null,
set: async () => null,
beforeAll(async () => {
const schema = new URL(databaseUrl!).searchParams.get('schema');
if (!schema?.endsWith('immediate_action_integration')) throw new Error(`Unsafe schema: ${schema}`);
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany({ where: { requestId: engineRequestId } });
await db.message.deleteMany({ where: { mailbox: { in: [actorId, targetId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [actorId, targetId] } } });
await db.general.deleteMany({ where: { id: { in: [actorId, targetId] } } });
await db.nation.deleteMany({ where: { id: { in: [actorNationId, targetNationId] } } });
await db.worldState.deleteMany({ where: { id: worldId } });
await db.worldState.create({
data: {
id: worldId,
scenarioCode: 'inherit-owner-message',
currentYear: 200,
currentMonth: 4,
tickSeconds: 600,
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue,
},
});
await db.nation.createMany({
data: [
{ id: actorNationId, name: actorNation.name, color: actorNation.color, level: 1 },
{ id: targetNationId, name: targetNation.name, color: targetNation.color, level: 1 },
],
});
await db.general.createMany({ data: [actor, target].map(toCreate) });
await db.inheritancePoint.create({ data: { userId: actorUserId, key: 'previous', value: 1_500 } });
await db.rankData.create({
data: { generalId: actorId, nationId: actorNationId, type: 'inherit_spent_dyn', value: 0 },
});
});
afterAll(async () => {
if (db) {
await db.inputEvent.deleteMany({ where: { requestId: engineRequestId } });
await db.message.deleteMany({ where: { mailbox: { in: [actorId, targetId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [actorId, targetId] } } });
await db.general.deleteMany({ where: { id: { in: [actorId, targetId] } } });
await db.nation.deleteMany({ where: { id: { in: [actorNationId, targetNationId] } } });
await db.worldState.deleteMany({ where: { id: worldId } });
}
await closeDb?.();
});
it('commits once and returns the durable result without double charging on the same API retry', async () => {
const snapshot: TurnWorldSnapshot = {
generals: [actor, target],
cities: [],
nations: [actorNation, targetNation],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig,
scenarioMeta,
map,
};
return {
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const queue = new DatabaseTurnDaemonCommandQueue(db);
await queue.initialize();
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const stateManager = new EngineStateManager();
stateManager.register('world', {
capture: () => world.captureState(),
restore: (value) => world.restoreState(value),
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new SystemClock(),
controlQueue: queue,
commandResponder: queue,
getNextTickTime: () => new Date(Date.now() + 3_600_000),
stateStore: new InMemoryTurnStateStore(world),
processor: {
run: async () => {
throw new Error('scheduled turn must not run in inheritance API integration');
},
},
commandHandler: createTurnDaemonCommandHandler({ world }),
hooks: hooks.hooks,
stateManager,
},
{ profile: 'inherit-owner-message', defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 } }
);
const redisClient = { get: async () => null, set: async () => null };
const context: GameApiContext = {
requestId,
db,
redis: redisClient as unknown as RedisConnector['client'],
turnDaemon: new InMemoryTurnDaemonTransport(),
turnDaemon: new DatabaseTurnDaemonTransport(db, 10_000),
battleSim: new InMemoryBattleSimTransport(),
profile: { id: 'che', scenario: 'inherit-owner-message', name: 'che:inherit-owner-message' },
uploadDir: 'uploads',
@@ -72,157 +262,46 @@ integration('inherit owner lookup private messages', () => {
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:inherit-owner-message'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
readModelOutbox: { wake: vi.fn() },
};
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.inputEvent.deleteMany({ where: { actorUserId } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } });
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } });
await db.readModelRevision.deleteMany({
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
});
await db.nation.createMany({
data: [
{ id: actorNationId, name: '확인국', color: '#123456', level: 2 },
{ id: checkedNationId, name: '피확인국', color: '#654321', level: 3 },
],
});
await db.general.createMany({
data: [
{
id: actorGeneralId,
userId: actorUserId,
name: '확인장수',
nationId: actorNationId,
cityId: 1,
npcState: 0,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: { ownerName: '확인자 계정' },
},
{
id: checkedGeneralId,
userId: checkedUserId,
name: '피확인장수',
nationId: checkedNationId,
cityId: 1,
npcState: 0,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
meta: { ownerName: '피확인 계정' },
},
],
});
await db.inheritancePoint.create({
data: { userId: actorUserId, key: 'previous', value: 1_500 },
});
const world = await db.worldState.create({
data: {
scenarioCode: 'inherit-owner-message',
currentYear: 200,
currentMonth: 4,
tickSeconds: 600,
config: { const: { inheritCheckOwnerPoint: 1_000 } },
meta: { isUnited: 0 },
},
});
worldStateId = world.id;
});
afterAll(async () => {
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
const outboxIds = outboxes.filter(({ payload }) => hasMailboxChange(payload)).map(({ id }) => id);
if (outboxIds.length > 0) {
await db.readModelOutbox.deleteMany({ where: { id: { in: outboxIds } } });
let loop: Promise<void> | undefined;
try {
loop = lifecycle.start();
const caller = appRouter.createCaller(context);
const expected = { ok: true, ownerName: '피확인 계정', targetName: '피확인장수' };
await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected);
await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected);
} finally {
await lifecycle.stop('inherit owner integration finished');
await loop;
await hooks.close();
}
await db.inputEvent.deleteMany({ where: { actorUserId } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } });
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } });
await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } });
await db.readModelRevision.deleteMany({
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
});
await db.worldState.delete({ where: { id: worldStateId } });
await closeDb?.();
});
it('commits the point charge, log, and both Ref-compatible private messages', async () => {
const requestId = 'integration:inherit-owner-message:success';
await expect(
appRouter.createCaller(buildContext(requestId)).inherit.checkOwner({ targetGeneralId: checkedGeneralId })
).resolves.toEqual({
ok: true,
ownerName: '피확인 계정',
targetName: '피확인장수',
});
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: actorUserId, key: 'previous' } },
})
db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId: actorUserId, key: 'previous' } } })
).resolves.toMatchObject({ value: 500 });
await expect(db.inheritanceLog.findMany({ where: { userId: actorUserId } })).resolves.toEqual([
expect.objectContaining({
year: 200,
month: 4,
text: '1000 포인트로 장수 소유자 확인',
}),
]);
const messages = await db.message.findMany({
where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } },
orderBy: { mailbox: 'asc' },
});
expect(messages).toHaveLength(2);
expect(
messages.map(({ mailbox, type, src, dest, message }) => ({ mailbox, type, src, dest, message }))
).toEqual([
{
mailbox: actorGeneralId,
type: 'private',
src: 0,
dest: actorGeneralId,
message: expect.objectContaining({
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
dest: expect.objectContaining({ generalId: actorGeneralId, generalName: '확인장수' }),
text: '피확인장수의 소유자는 피확인 계정 입니다.',
}),
},
{
mailbox: checkedGeneralId,
type: 'private',
src: 0,
dest: checkedGeneralId,
message: expect.objectContaining({
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
dest: expect.objectContaining({ generalId: checkedGeneralId, generalName: '피확인장수' }),
text: '소유자명이 누군가에 의해 확인되었습니다.',
}),
},
]);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestId}:inherit.checkOwner` } })
).resolves.toMatchObject({ status: 'SUCCEEDED', actorUserId });
await expect(
db.readModelRevision.findMany({
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
orderBy: { entityId: 'asc' },
db.rankData.findUniqueOrThrow({
where: { generalId_type: { generalId: actorId, type: 'inherit_spent_dyn' } },
})
).resolves.toEqual([
expect.objectContaining({ domain: 'messages.mailbox', entityId: actorGeneralId, revision: 1n }),
expect.objectContaining({ domain: 'messages.mailbox', entityId: checkedGeneralId, revision: 1n }),
).resolves.toMatchObject({ value: 1_000 });
await expect(db.inheritanceLog.count({ where: { userId: actorUserId } })).resolves.toBe(1);
const messages = await db.message.findMany({
where: { mailbox: { in: [actorId, targetId] } },
orderBy: { id: 'asc' },
select: { mailbox: true, message: true },
});
expect(messages.map(({ mailbox, message }) => [mailbox, (message as { text: string }).text])).toEqual([
[actorId, '피확인장수의 소유자는 피확인 계정 입니다.'],
[targetId, '소유자명이 누군가에 의해 확인되었습니다.'],
]);
});
await expect(db.inputEvent.findMany({ where: { requestId: engineRequestId } })).resolves.toEqual([
expect.objectContaining({ status: 'SUCCEEDED', attempts: 1, actorUserId }),
]);
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find((entry) => entry.id === actorId)).toMatchObject({
meta: { inherit_spent_dyn: 1_000 },
inheritancePoints: { previous: 500 },
});
}, 30_000);
});
+158 -133
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { ChangeJournal } from '@sammo-ts/common';
import { ChangeJournal, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import type { MessagePayload } from '@sammo-ts/logic';
@@ -111,6 +111,7 @@ const buildContext = (options: {
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
configConst?: Record<string, unknown>;
configMap?: Record<string, unknown>;
daemonResult?: TurnDaemonCommandResult;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
@@ -118,11 +119,19 @@ const buildContext = (options: {
options.target === undefined
? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } })
: options.target;
const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({
type: command.type,
ok: true,
generalId: command.generalId,
}));
const requestCommand = vi.fn(async (command: TurnDaemonCommand): Promise<TurnDaemonCommandResult> => {
if (options.daemonResult) return options.daemonResult;
if (command.type === 'inheritanceAction') {
return {
type: 'inheritanceAction',
ok: true,
action: command.input.action,
generalId: general?.id ?? 7,
remainPoint: options.inheritancePoint ?? 10_000,
};
}
return { type: 'patchGeneral', ok: true, generalId: 7 };
});
const pointUpsert = vi.fn(async () => ({}));
const logCreate = vi.fn(async () => ({}));
const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : []));
@@ -272,10 +281,17 @@ describe('inherit router actor and permission boundaries', () => {
});
});
it('reports and enforces the Ref S100 stat-reset ban without dispatching or charging', async () => {
it('reports the Ref S100 stat-reset ban and maps the authoritative daemon rejection', async () => {
const fixture = buildContext({
configMap: { targetGeneralPool: 'SPoolUnderU100' },
inheritancePoint: 0,
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'resetStat',
code: 'BAD_REQUEST',
reason: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
},
});
const caller = appRouter.createCaller(fixture.context);
@@ -291,7 +307,17 @@ describe('inherit router actor and permission boundaries', () => {
code: 'BAD_REQUEST',
message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'inheritanceAction',
userId: 'user-1',
input: {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1],
},
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
@@ -429,10 +455,17 @@ describe('inherit router actor and permission boundaries', () => {
expect(fixture.inheritanceLogFindMany).not.toHaveBeenCalled();
});
it('does not dispatch or charge when the authenticated user owns no general', async () => {
it('delegates the authenticated actor and maps a missing-general daemon rejection', async () => {
const fixture = buildContext({
auth: buildAuth('user-2'),
general: buildGeneral({ userId: 'user-1' }),
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'buyHiddenBuff',
code: 'PRECONDITION_FAILED',
reason: '장수가 존재하지 않습니다.',
},
});
await expect(
@@ -444,12 +477,25 @@ describe('inherit router actor and permission boundaries', () => {
code: 'PRECONDITION_FAILED',
message: '장수가 존재하지 않습니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'inheritanceAction',
userId: 'user-2',
input: { action: 'buyHiddenBuff', buffType: 'domesticSuccessProb', level: 1 },
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
});
it('mutates only the authenticated user general and inheritance balance', async () => {
const fixture = buildContext({ inheritancePoint: 1000 });
const fixture = buildContext({
inheritancePoint: 1000,
daemonResult: {
type: 'inheritanceAction',
ok: true,
action: 'buyHiddenBuff',
generalId: 7,
remainPoint: 800,
},
});
await expect(
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
@@ -458,29 +504,26 @@ describe('inherit router actor and permission boundaries', () => {
})
).resolves.toEqual({ ok: true, remainPoint: 800 });
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
type: 'patchGeneral',
generalId: 7,
patch: expect.objectContaining({
meta: expect.objectContaining({
inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }),
}),
}),
})
);
expect(fixture.pointUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 800 },
})
);
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'inheritanceAction',
userId: 'user-1',
input: { action: 'buyHiddenBuff', buffType: 'domesticSuccessProb', level: 1 },
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('reserves the selected Ref war trait and charges the authenticated owner once', async () => {
const fixture = buildContext({
inheritancePoint: 5_000,
configConst: { availableSpecialWar: ['che_의술'] },
daemonResult: {
type: 'inheritanceAction',
ok: true,
action: 'setNextSpecialWar',
generalId: 7,
remainPoint: 1_000,
},
});
await expect(
@@ -488,19 +531,12 @@ describe('inherit router actor and permission boundaries', () => {
).resolves.toEqual({ ok: true });
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'patchGeneral',
generalId: 7,
patch: { meta: { inheritSpecificSpecialWar: 'che_의술' } },
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '4000 포인트로 다음 전투 특기로 의술 지정',
},
type: 'inheritanceAction',
userId: 'user-1',
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' },
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('does not dispatch or charge when a different war trait is already reserved', async () => {
@@ -508,12 +544,19 @@ describe('inherit router actor and permission boundaries', () => {
inheritancePoint: 5_000,
general: buildGeneral({ meta: { inheritSpecificSpecialWar: 'che_신산' } }),
configConst: { availableSpecialWar: ['che_의술'] },
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'setNextSpecialWar',
code: 'BAD_REQUEST',
reason: '이미 예약한 특기가 있습니다.',
},
});
await expect(
appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' })
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' });
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledOnce();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
@@ -522,41 +565,44 @@ describe('inherit router actor and permission boundaries', () => {
const fixture = buildContext({
inheritancePoint: 2_000,
general: buildGeneral({ meta: { prev_types_special2: ['che_돌격'], marker: 3 } }),
daemonResult: {
type: 'inheritanceAction',
ok: true,
action: 'resetSpecialWar',
generalId: 7,
remainPoint: 1_000,
},
});
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).resolves.toEqual({ ok: true });
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'patchGeneral',
generalId: 7,
patch: {
specialWar: null,
meta: {
prev_types_special2: ['che_돌격', 'che_선봉'],
marker: 3,
inheritResetSpecialWar: 0,
},
},
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '1000 포인트로 전투 특기 초기화',
},
type: 'inheritanceAction',
userId: 'user-1',
input: { action: 'resetSpecialWar' },
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('does not dispatch or charge when the current war trait is already blank', async () => {
const fixture = buildContext({ inheritancePoint: 2_000, general: buildGeneral({ special2Code: 'None' }) });
const fixture = buildContext({
inheritancePoint: 2_000,
general: buildGeneral({ special2Code: 'None' }),
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'resetSpecialWar',
code: 'BAD_REQUEST',
reason: '이미 전투 특기가 공란입니다.',
},
});
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '이미 전투 특기가 공란입니다.',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledOnce();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
@@ -572,34 +618,41 @@ describe('inherit router actor and permission boundaries', () => {
previousTurnTimeBase: 123_456,
tickSeconds: worldState.tickSeconds,
});
fixture.requestCommand.mockResolvedValueOnce({
type: 'inheritanceAction',
ok: true,
action: 'resetTurnTime',
generalId: 7,
remainPoint: 1_000,
...expected,
});
await expect(appRouter.createCaller(fixture.context).inherit.resetTurnTime()).resolves.toEqual({
ok: true,
...expected,
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'patchGeneral',
generalId: 7,
patch: {
meta: {
nextTurnTimeBase: expected.nextTurnTimeBase,
inheritResetTurnTime: 0,
},
},
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: `1000 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${expected.nextTurnTimeLabel} 적용`,
},
type: 'inheritanceAction',
userId: 'user-1',
input: { action: 'resetTurnTime' },
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('reveals a target owner to the caller without using the caller general id from input', async () => {
const fixture = buildContext({ inheritancePoint: 1500 });
const fixture = buildContext({
inheritancePoint: 1500,
daemonResult: {
type: 'inheritanceAction',
ok: true,
action: 'checkOwner',
generalId: 7,
remainPoint: 500,
ownerName: '위유저',
targetName: '조조',
},
});
await expect(
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
@@ -608,64 +661,27 @@ describe('inherit router actor and permission boundaries', () => {
ownerName: '위유저',
targetName: '조조',
});
expect(fixture.pointUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId_key: { userId: 'user-1', key: 'previous' } },
update: { value: 500 },
})
);
expect(fixture.logCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
year: 200,
month: 4,
text: '1000 포인트로 장수 소유자 확인',
},
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'inheritanceAction',
userId: 'user-1',
input: { action: 'checkOwner', targetGeneralId: 8 },
});
expect(fixture.messageRows).toHaveLength(2);
expect(fixture.messageRows).toEqual([
expect.objectContaining({
mailbox: 7,
type: 'private',
src: 0,
dest: 7,
payload: expect.objectContaining({
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
dest: expect.objectContaining({ generalId: 7, generalName: '유비' }),
text: '조조의 소유자는 위유저 입니다.',
}),
}),
expect.objectContaining({
mailbox: 8,
type: 'private',
src: 0,
dest: 8,
payload: expect.objectContaining({
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
dest: expect.objectContaining({ generalId: 8, generalName: '조조' }),
text: '소유자명이 누군가에 의해 확인되었습니다.',
}),
}),
]);
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(1, {
data: [{ eventId: 'message:101', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-1'] }],
skipDuplicates: true,
});
expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(2, {
data: [{ eventId: 'message:102', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-2'] }],
skipDuplicates: true,
});
expect(fixture.changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 7 },
{ domain: 'messages.mailbox', entityId: 8 },
]);
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
expect(fixture.messageRows).toHaveLength(0);
});
it('does not charge or send messages when the owner lookup target is the actor', async () => {
const fixture = buildContext({
inheritancePoint: 1_500,
target: buildGeneral({ id: 7, userId: 'user-1', name: '유비' }),
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'checkOwner',
code: 'BAD_REQUEST',
reason: '자신의 정보는 확인할 수 없습니다.',
},
});
await expect(
@@ -681,13 +697,22 @@ describe('inherit router actor and permission boundaries', () => {
});
it('does not charge or send messages when inheritance points are insufficient', async () => {
const fixture = buildContext({ inheritancePoint: 999 });
const fixture = buildContext({
inheritancePoint: 999,
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'checkOwner',
code: 'BAD_REQUEST',
reason: '충분한 유산 포인트를 가지고 있지 않습니다.',
},
});
await expect(
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '유산 포인트가 부족합니다.',
message: '충분한 유산 포인트를 가지고 있지 않습니다.',
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
+45 -5
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { RANK_DATA_TYPES } from '@sammo-ts/common';
@@ -108,6 +108,7 @@ const buildContext = (options?: {
profileId?: string;
generals?: RankingGeneralRow[];
rankRows?: Array<{ generalId: number; type: string; value: number }>;
gameHistoryFindMany?: (args: unknown) => Promise<Array<{ season: number; scenario: number; scenarioName: string }>>;
}): GameApiContext => {
const selectedGeneralRows = options?.generals ?? generalRows;
const selectedProfile = options?.profileId
@@ -198,10 +199,12 @@ const buildContext = (options?: {
findMany: async () => [{ targetCode: 'che_명마_15_적토마' }],
},
gameHistory: {
findMany: async () => [
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
],
findMany:
options?.gameHistoryFindMany ??
(async () => [
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
]),
},
hallOfFame: {
findMany: async (args: { where: { type: string } }) =>
@@ -303,6 +306,28 @@ describe('ranking.getBestGeneral', () => {
});
});
it('excludes 100th-season event mastery from current Best General values', async () => {
const generals = [
{
...generalRows[0]!,
meta: {
...generalRows[0]!.meta,
dex1: 120,
event100_allstar: { granted: { dex1: 70 } },
} as unknown as RankingGeneralRow['meta'],
},
];
const result = await appRouter.createCaller(buildContext({ generals })).ranking.getBestGeneral({
view: 'user',
});
expect(result.sections.find((section) => section.title === '보 병 숙 련 도')?.entries[0]).toMatchObject({
id: 1,
value: 50,
printValue: '50',
});
});
it('returns positions one through ten for every populated ranking section', async () => {
const generals = Array.from({ length: 12 }, (_, index) => {
const id = index + 1;
@@ -386,6 +411,21 @@ describe('ranking hall of fame', () => {
]);
});
it('includes the active OPEN game while excluding ABANDONED options', async () => {
const findMany = vi.fn(async () => [
{ season: 4, scenario: 23, scenarioName: '현재 시나리오' },
{ season: 3, scenario: 22, scenarioName: '완료 시나리오' },
]);
const options = await appRouter
.createCaller(buildContext({ authenticated: false, gameHistoryFindMany: findMany }))
.ranking.getHallOfFameOptions();
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { status: { in: ['OPEN', 'COMPLETED'] } } })
);
expect(options.map((entry) => entry.season)).toEqual([4, 3]);
});
it('scopes previous-server options and rankings to the request profile', async () => {
const cheCaller = appRouter.createCaller(buildContext({ authenticated: false }));
await expect(cheCaller.ranking.getHallOfFameOptions({ source: 'legacy' })).resolves.toEqual([