Files
core2026/app/game-engine/test/inheritanceActionService.test.ts
T
Hide_D 4fc20de8d4 fix: 은퇴 명예 기록과 유산 수명주기 정합성을 복원한다
은퇴·사망·통일의 저장 순서와 명예의 전당 및 명장일람 판정을 Ref 흐름에 맞춘다.

유산 행동을 인증된 daemon transaction으로 통합하고 중복 지급·고유 아이템·로그·오류 경계를 회귀 테스트한다.
2026-08-24 03:38:12 +00:00

358 lines
13 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { LiteHashDRBG, RandUtil, type TurnDaemonCommand } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import {
buildResetStatRandomBonus,
executeInheritanceAction,
resolveOwnerDisplayName,
} from '../src/turn/inheritanceActionService.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
type InheritanceCommand = Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>;
const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
id: 1,
userId: 'user-1',
name: '유비',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 45, intelligence: 85 },
experience: 0,
dedication: 0,
officerLevel: 1,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, inherit_spent_dyn: 17 },
inheritancePoints: { previous: 10_000 },
turnTime: new Date('0200-04-01T00:00:00.000Z'),
...overrides,
});
const buildWorld = (options: {
general?: TurnGeneral;
target?: TurnGeneral;
worldMeta?: Record<string, unknown>;
configConst?: Record<string, unknown>;
configMap?: Record<string, unknown>;
}) => {
const state: TurnWorldState = {
id: 1,
currentYear: 200,
currentMonth: 4,
tickSeconds: 3_600,
lastTurnTime: new Date('0200-04-01T00:00:00.000Z'),
meta: { hiddenSeed: 'test-seed', season: 7, isunited: 0, ...(options.worldMeta ?? {}) },
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: options.configMap ?? {},
const: {
availableSpecialWar: ['che_의술'],
inheritBornStatPoint: 1_000,
inheritItemRandomPoint: 3_000,
inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000],
inheritSpecificSpecialPoint: 4_000,
inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000],
inheritCheckOwnerPoint: 1_000,
...(options.configConst ?? {}),
},
environment: { mapName: 'test', unitSet: 'default' },
},
map: { id: 'test', name: 'test', cities: [] },
generals: [options.general ?? buildGeneral(), ...(options.target ? [options.target] : [])],
cities: [],
nations: [
{
id: 1,
name: '촉',
color: '#ff0000',
capitalCityId: null,
chiefGeneralId: 1,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
},
],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
return new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
};
const buildDatabase = (options: { point?: number; resetSeasons?: number[] } = {}) => {
const createLog = vi.fn(async () => ({}));
const findUserState = vi.fn(async () =>
options.resetSeasons ? { meta: { last_stat_reset: options.resetSeasons } } : null
);
const upsertUserState = vi.fn(async () => ({}));
const queryRaw = vi.fn(async () => [{ value: options.point ?? 10_000 }]);
return {
db: {
$queryRaw: queryRaw,
inheritanceLog: { create: createLog },
inheritanceUserState: { findUnique: findUserState, upsert: upsertUserState },
} as unknown as GamePrisma.TransactionClient,
createLog,
findUserState,
upsertUserState,
queryRaw,
};
};
const execute = async (
world: InMemoryTurnWorld,
db: GamePrisma.TransactionClient,
input: InheritanceCommand['input']
) =>
executeInheritanceAction({
db,
world,
command: { type: 'inheritanceAction', userId: 'user-1', input },
gameNow: new Date('0200-04-01T00:00:00.000Z'),
});
describe('inheritance action service', () => {
it.each([
{
name: 'hidden buff',
input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 } as const,
cost: 200,
general: buildGeneral(),
},
{
name: 'specific special',
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' } as const,
cost: 4_000,
general: buildGeneral(),
},
{
name: 'special reset',
input: { action: 'resetSpecialWar' } as const,
cost: 1_000,
general: buildGeneral({ role: { ...buildGeneral().role, specialWar: 'che_선봉' } }),
},
{
name: 'turn-time reset',
input: { action: 'resetTurnTime' } as const,
cost: 1_000,
general: buildGeneral(),
},
{
name: 'paid stat reset',
input: {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1] as [number, number, number],
} as const,
cost: 1_000,
general: buildGeneral(),
},
{
name: 'random unique reservation',
input: { action: 'buyRandomUnique' } as const,
cost: 3_000,
general: buildGeneral(),
},
{
name: 'owner lookup',
input: { action: 'checkOwner', targetGeneralId: 2 } as const,
cost: 1_000,
general: buildGeneral(),
},
])('charges $name in runtime rank, points, and the same dirty flush', async ({ input, cost, general }) => {
const target =
input.action === 'checkOwner'
? buildGeneral({
id: 2,
userId: 'user-2',
name: '조조',
meta: { killturn: 24, owner_name: '위유저' },
})
: undefined;
const world = buildWorld({ general, target });
const { db, createLog } = buildDatabase();
await expect(execute(world, db, input)).resolves.toMatchObject({ ok: true, remainPoint: 10_000 - cost });
expect(world.getGeneralById(1)).toMatchObject({
meta: { inherit_spent_dyn: 17 + cost },
inheritancePoints: { previous: 10_000 - cost },
});
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual(
cost === 0 ? [] : [{ userId: 'user-1', key: 'previous', amount: -cost }]
);
expect(createLog).toHaveBeenCalled();
});
it('keeps free ResetStat at zero spend and uses the Ref-compatible fixed-seed bonus', async () => {
const world = buildWorld({});
const { db } = buildDatabase({ point: 0 });
const result = await execute(world, db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [0, 0, 0],
});
expect(result).toMatchObject({ ok: true, remainPoint: 0 });
expect(world.getGeneralById(1)?.meta.inherit_spent_dyn).toBe(17);
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([]);
expect(result.ok && result.stats).toEqual({ leadership: 73, strength: 45, intel: 87 });
});
it('matches the Ref ResetStat DRBG seed, inclusive 3..5 count, and weighted choices', () => {
const bonus = buildResetStatRandomBonus(
new RandUtil(new LiteHashDRBG(simpleSerialize('test-seed', 'ResetStat', 'user-1'))),
[70, 45, 85]
);
expect(bonus).toEqual([3, 0, 2]);
expect(bonus.reduce((sum, value) => sum + value, 0)).toBeGreaterThanOrEqual(3);
expect(bonus.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(5);
});
it.each([1, 2])('rejects npcState=%s ResetStat exactly like Ref npc != 0', async (npcState) => {
const world = buildWorld({ general: buildGeneral({ npcState }) });
const { db, queryRaw } = buildDatabase();
await expect(
execute(world, db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1],
})
).resolves.toMatchObject({ ok: false, reason: 'NPC는 능력치 초기화를 할 수 없습니다.' });
expect(queryRaw).not.toHaveBeenCalled();
});
it.each([
{
name: 'purchased buff before unification',
general: buildGeneral({ meta: { killturn: 24, inheritBuff: JSON.stringify({ warAvoidRatio: 1 }) } }),
input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 } as const,
reason: '이미 구입했습니다.',
},
{
name: 'owned special before unification',
general: buildGeneral({ role: { ...buildGeneral().role, specialWar: 'che_의술' } }),
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' } as const,
reason: '이미 그 특기를 보유하고 있습니다.',
},
{
name: 'blank special before unification',
general: buildGeneral(),
input: { action: 'resetSpecialWar' } as const,
reason: '이미 전투 특기가 공란입니다.',
},
{
name: 'random reservation before unification',
general: buildGeneral({ meta: { killturn: 24, inheritRandomUnique: true } }),
input: { action: 'buyRandomUnique' } as const,
reason: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.',
},
])('preserves Ref combined-invalid precedence: $name', async ({ general, input, reason }) => {
const world = buildWorld({ general, worldMeta: { isunited: 1 } });
const { db, queryRaw } = buildDatabase();
await expect(execute(world, db, input)).resolves.toMatchObject({ ok: false, reason });
expect(queryRaw).not.toHaveBeenCalled();
});
it('checks ResetStat shape, npc/S100, unification, season duplicate, then points', async () => {
const npcWorld = buildWorld({ general: buildGeneral({ npcState: 1 }), worldMeta: { isunited: 1 } });
const npcDb = buildDatabase({ point: 0 });
await expect(
execute(npcWorld, npcDb.db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 84,
inheritBonusStat: [2, 1, 1],
})
).resolves.toMatchObject({ ok: false, reason: '능력치 총합이 200이 아닙니다. 다시 입력해주세요!' });
const seasonWorld = buildWorld({});
const seasonDb = buildDatabase({ point: 0, resetSeasons: [7] });
await expect(
execute(seasonWorld, seasonDb.db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1],
})
).resolves.toMatchObject({ ok: false, reason: '이번 시즌에 이미 능력치를 초기화하셨습니다.' });
expect(seasonDb.queryRaw).not.toHaveBeenCalled();
});
it('uses both unification keys and preserves owner display-name compatibility order', async () => {
const world = buildWorld({ worldMeta: { isUnited: 1, isunited: 0 } });
const { db } = buildDatabase();
await expect(execute(world, db, { action: 'resetTurnTime' })).resolves.toMatchObject({
ok: false,
reason: '이미 천하가 통일되었습니다.',
});
expect(resolveOwnerDisplayName({ ownerDisplayName: '현재', owner_name: '레거시', ownerName: '호환' })).toBe(
'현재'
);
expect(resolveOwnerDisplayName({ owner_name: '레거시', ownerName: '호환' })).toBe('레거시');
expect(resolveOwnerDisplayName({ ownerName: '호환' })).toBe('호환');
expect(resolveOwnerDisplayName({})).toBe('알수없음');
});
it('queues CheckOwner messages in Ref requester-then-target order', async () => {
const world = buildWorld({
target: buildGeneral({
id: 2,
userId: 'user-2',
name: '조조',
meta: { killturn: 24, owner_name: '위유저' },
}),
});
const { db } = buildDatabase();
await expect(execute(world, db, { action: 'checkOwner', targetGeneralId: 2 })).resolves.toMatchObject({
ok: true,
ownerName: '위유저',
});
expect(world.peekDirtyState().messages.map((message) => [message.dest.generalId, message.text])).toEqual([
[1, '조조의 소유자는 위유저 입니다.'],
[2, '소유자명이 누군가에 의해 확인되었습니다.'],
]);
});
});