fix: 은퇴 명예 기록과 유산 수명주기 정합성을 복원한다
은퇴·사망·통일의 저장 순서와 명예의 전당 및 명장일람 판정을 Ref 흐름에 맞춘다. 유산 행동을 인증된 daemon transaction으로 통합하고 중복 지급·고유 아이템·로그·오류 경계를 회귀 테스트한다.
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
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, '소유자명이 누군가에 의해 확인되었습니다.'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user