feat: integrate unique lottery system into general actions

- Added `tryApplyUniqueLottery` function to various action definitions to apply unique item rewards based on specific actions.
- Enhanced the `uniqueLottery.ts` module to handle unique item acquisition and logging.
- Created unit tests for the unique lottery feature to ensure proper functionality during general commands.
- Updated action definitions to include unique item acquisition for actions such as training, technology research, and item procurement.
This commit is contained in:
2026-02-04 12:03:47 +00:00
parent a2bb667b57
commit 568084927c
30 changed files with 461 additions and 2 deletions
@@ -20,6 +20,15 @@ import {
defaultActionContextBuilder,
evaluateConstraints,
resolveGeneralAction,
ITEM_KEYS,
buildGenericUniqueSeed,
countOccupiedUniqueItems,
createItemModuleRegistry,
loadItemModules,
resolveUniqueConfig,
rollUniqueLottery,
type ItemModule,
type UniqueLotteryRunner,
} from '@sammo-ts/logic';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
@@ -76,6 +85,122 @@ const serializeSeed = (...values: Array<string | number>): string =>
.map((value) => (typeof value === 'string' ? `str(${value.length},${value})` : `int(${Math.floor(value)})`))
.join('|');
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: number): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.floor(value);
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return Math.floor(parsed);
}
}
return fallback;
};
const readMetaBool = (meta: Record<string, unknown>, key: string, fallback = false): boolean => {
const value = meta[key];
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const lowered = value.toLowerCase();
if (lowered === 'true' || lowered === '1') {
return true;
}
if (lowered === 'false' || lowered === '0') {
return false;
}
}
return fallback;
};
const resolveStartYear = (world: TurnWorldState, scenarioMeta?: ScenarioMeta): number => {
if (typeof scenarioMeta?.startYear === 'number') {
return scenarioMeta.startYear;
}
const worldMeta = asRecord(world.meta);
const scenarioMetaRecord = asRecord(worldMeta.scenarioMeta);
return readMetaNumber(scenarioMetaRecord, 'startYear', world.currentYear);
};
const buildUniqueLotteryRunner = (options: {
world: TurnWorldState;
worldView: WorldView | null;
scenarioMeta?: ScenarioMeta;
seedBase: string;
itemRegistry: Map<string, ItemModule>;
uniqueConfig: ReturnType<typeof resolveUniqueConfig>;
}): UniqueLotteryRunner => {
if (!options.worldView) {
return () => null;
}
const worldView = options.worldView;
const world = options.world;
const worldMeta = asRecord(world.meta);
const startYear = resolveStartYear(world, options.scenarioMeta);
const initYear = readMetaNumber(worldMeta, 'initYear', startYear);
const initMonth = readMetaNumber(worldMeta, 'initMonth', 1);
const scenarioId = readMetaNumber(worldMeta, 'scenarioId', 0);
const minMonthToAllowInherit = options.uniqueConfig.minMonthToAllowInheritItem;
return ({ acquireType, reason, general }) => {
if (general.npcState >= 2) {
return null;
}
const allGenerals = worldView.listGenerals();
const userCount = allGenerals.filter((entry) => entry.npcState < 2).length;
if (userCount <= 0) {
return null;
}
const generalItemsList = allGenerals.map((entry) =>
entry.id === general.id ? general.role.items : entry.role.items
);
const occupiedUniqueCounts = countOccupiedUniqueItems(generalItemsList, options.itemRegistry);
const rngSeed = buildGenericUniqueSeed(
options.seedBase,
world.currentYear,
world.currentMonth,
general.id,
reason
);
const rng = new RandUtil(LiteHashDRBG.build(rngSeed));
const inheritRandomUnique = readMetaBool(asRecord(general.meta), 'inheritRandomUnique', false);
const relMonthByInit =
joinYearMonth(world.currentYear, world.currentMonth) - joinYearMonth(initYear, initMonth);
const availableBuyUnique = relMonthByInit >= minMonthToAllowInherit;
const itemKey = rollUniqueLottery({
rng,
config: options.uniqueConfig,
itemRegistry: options.itemRegistry,
generalItems: general.role.items,
occupiedUniqueCounts,
scenarioId,
userCount,
currentYear: world.currentYear,
currentMonth: world.currentMonth,
startYear,
initYear,
initMonth,
acquireType,
inheritRandomUnique,
});
if (!itemKey) {
return null;
}
if (inheritRandomUnique && availableBuyUnique) {
delete asRecord(general.meta).inheritRandomUnique;
}
return options.itemRegistry.get(itemKey) ?? null;
};
};
type WorldView = {
getGeneralById(id: number): TurnGeneral | null;
@@ -349,6 +474,8 @@ export const createReservedTurnHandler = async (options: {
}) => void;
}): Promise<GeneralTurnHandler> => {
const env = buildCommandEnv(options.scenarioConfig, options.unitSet);
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const));
const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE;
const { general: generalDefinitions, nation: nationDefinitions } = await buildReservedTurnDefinitions({
env,
@@ -518,11 +645,20 @@ export const createReservedTurnHandler = async (options: {
};
const actionArgsRecord = extractArgsRecord(actionArgs);
const uniqueLottery = buildUniqueLotteryRunner({
world: context.world,
worldView,
scenarioMeta: options.scenarioMeta,
seedBase,
itemRegistry,
uniqueConfig,
});
let baseContext: ActionContextBase = {
general: currentGeneral,
city: currentCity,
nation: currentNation,
rng: buildRng(actionKey),
uniqueLottery,
};
let specificContext = buildActionContext(
actionKey,
@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
import type { TurnSchedule } from '@sammo-ts/logic';
const buildGeneral = (id: number): TurnGeneral => ({
id,
name: `General_${id}`,
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 50, strength: 50, intelligence: 50 },
turnTime: new Date('0180-01-01T00:00:00Z'),
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
officerLevel: 1,
experience: 0,
dedication: 0,
injury: 0,
gold: 1000,
rice: 1000,
crew: 100,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
});
describe('unique lottery on general commands', () => {
it('awards a unique item for eligible commands', async () => {
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const generals = [buildGeneral(1)];
const snapshot: TurnWorldSnapshot = {
generals: generals as any,
cities: [
{
id: 1,
name: 'City_1',
nationId: 1,
viewName: 'City_1',
agriculture: 100,
agricultureMax: 2000,
commerce: 100,
commerceMax: 2000,
security: 100,
securityMax: 100,
def: 100,
defMax: 100,
wall: 100,
wallMax: 100,
pop: 10000,
popMax: 50000,
trust: 50,
supplyState: 1,
frontState: 0,
tradepoint: 0,
level: 1,
meta: {},
},
] as any,
nations: [
{
id: 1,
name: 'TestNation',
color: '#FF0000',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 10000,
rice: 10000,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
},
] as any,
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: {
id: 'test_map',
name: 'TestMap',
cities: [
{
id: 1,
name: 'City_1',
level: 1,
region: 1,
position: { x: 0, y: 0 },
connections: [],
max: {} as any,
initial: {} as any,
},
],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
} as any,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {
allItems: {
weapon: {
che_무기_12_칠성검: 1,
},
},
maxUniqueItemLimit: [[-1, 1]],
uniqueTrialCoef: 10,
maxUniqueTrialProb: 10,
minMonthToAllowInheritItem: 0,
},
environment: { mapName: 'test_map', unitSet: 'default' },
},
scenarioMeta: {
startYear: 180,
} as any,
unitSet: {} as any,
};
const state: TurnWorldState = {
id: 1,
currentYear: 180,
currentMonth: 1,
tickSeconds: 3600,
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
meta: {
hiddenSeed: 'seed',
scenarioId: 200,
initYear: 180,
initMonth: 1,
scenarioMeta: { startYear: 180 },
},
};
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const reservedTurns = new InMemoryReservedTurnStore(
{
generalTurn: { findMany: async () => [] },
nationTurn: { findMany: async () => [] },
} as any,
{ maxGeneralTurns: 30, maxNationTurns: 12 }
);
reservedTurns.getGeneralTurns(1)[0] = { action: 'che_훈련', args: {} };
const handler = await createReservedTurnHandler({
reservedTurns,
scenarioConfig: snapshot.scenarioConfig,
scenarioMeta: snapshot.scenarioMeta,
map: snapshot.map,
unitSet: snapshot.unitSet,
getWorld: () => world,
});
const result = handler.execute({
general: world.getGeneralById(1)!,
city: world.getCityById(1)!,
nation: world.getNationById(1)!,
world: world.getState(),
schedule,
});
expect(result.general?.role.items.weapon).toBe('che_무기_12_칠성검');
const logTexts = (result.logs ?? []).map((entry) => entry.text);
expect(logTexts.some((text) => text.includes('【아이템】'))).toBe(true);
});
});