feat: complete legacy unique item battle parity

This commit is contained in:
2026-07-25 06:47:56 +00:00
parent 35935c9eca
commit 0d7ede3d93
45 changed files with 1143 additions and 174 deletions
+11
View File
@@ -10,6 +10,7 @@ import {
projectItemSlots,
serializeItemInventory,
} from '../src/items/inventory.js';
import { listEquippedItemKeys } from '../src/items/utils.js';
const makeGeneral = (): General => ({
id: 7,
@@ -58,6 +59,16 @@ describe('GeneralItemInventory', () => {
expect(Object.keys(inventory.instances)).toHaveLength(3);
});
it('reads legacy slots without mutating a frozen world snapshot', () => {
const general = makeGeneral();
general.role.items.item = 'che_치료_환약';
const frozen = Object.freeze(general);
expect(listEquippedItemKeys(frozen)).toEqual(['che_치료_환약']);
expect(getEquippedItemInstance(frozen, 'item')?.itemKey).toBe('che_치료_환약');
expect(frozen.itemInventory).toBeUndefined();
});
it('keeps consumable charges in the equipped item instance', () => {
const general = makeGeneral();
equipNewItem(general, 'item', 'che_치료_환약', { charges: 3 });
@@ -14,7 +14,15 @@ import { commandSpec as spySpec } from '../../src/actions/turn/general/che_첩
import { commandSpec as destroySpec } from '../../src/actions/turn/general/che_파괴.js';
import { commandSpec as agitateSpec } from '../../src/actions/turn/general/che_선동.js';
import { commandSpec as seizeSpec } from '../../src/actions/turn/general/che_탈취.js';
import { commandSpec as fireSpec } from '../../src/actions/turn/general/che_화계.js';
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
import {
createItemActionModules,
createItemModuleRegistry,
equipNewItem,
getEquippedItemInstance,
loadItemModules,
} from '../../src/items/index.js';
describe('General Commands New Scenario', () => {
// 1. Setup Environment
@@ -357,6 +365,7 @@ describe('General Commands New Scenario', () => {
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {},
});
equipNewItem(gen1, 'item', 'che_계략_이추');
const gen2: General = { id: 2, name: 'G2', nationId: 2, cityId: 2, troopId: 0, npcState: 0, ticket: 0 } as any;
Object.assign(gen2, {
@@ -392,6 +401,16 @@ describe('General Commands New Scenario', () => {
const world = new InMemoryWorld(snapshot);
const runner = new TestGameRunner(world, 200, 1);
const itemGeneralModules = createItemActionModules(
createItemModuleRegistry(await loadItemModules(['che_계략_이추', 'che_계략_향낭']))
).general;
const reEquipStrategyItem = (itemKey: 'che_계략_이추' | 'che_계략_향낭'): void => {
const nextGeneral = structuredClone(world.getGeneral(1)!);
equipNewItem(nextGeneral, 'item', itemKey);
world.snapshot.generals = world.snapshot.generals.map((general) =>
general.id === nextGeneral.id ? nextGeneral : general
);
};
// 1. Employ (G1 -> G2)
const employDef = employSpec.createDefinition(systemEnv);
@@ -424,7 +443,8 @@ describe('General Commands New Scenario', () => {
expect(spyInfo['2']).toBe(3); // City 2 spied level 3
// 3. Destroy (G1 -> C2)
const destroyDef = destroySpec.createDefinition(systemEnv);
const strategyEnv = { ...systemEnv, generalActionModules: itemGeneralModules };
const destroyDef = destroySpec.createDefinition(strategyEnv);
await runner.runTurn([
{
generalId: 1,
@@ -438,9 +458,42 @@ describe('General Commands New Scenario', () => {
const c2_after_destroy = world.getCity(2)!;
expect(c2_after_destroy.defence).toBeLessThan(1000);
expect(c2_after_destroy.state).toBe(32);
expect(getEquippedItemInstance(world.getGeneral(1)!, 'item')).toBeNull();
// 4. Agitate (G1 -> C2)
const agitateDef = agitateSpec.createDefinition(systemEnv);
// 4. Fire attack consumes the successful one-use strategy item.
reEquipStrategyItem('che_계략_향낭');
const fireDef = fireSpec.createDefinition(strategyEnv);
await runner.runTurn([
{
generalId: 1,
commandKey: 'che_화계',
resolver: fireDef,
args: { destCityId: 2 },
context: {
destCity: city2,
destNation: nation2,
destGenerals: [gen2],
env: systemEnv,
map: MINIMAL_MAP,
rng: {
real: () => 0,
int: (min: number, _max: number) => min,
nextInt: (min: number, _max: number) => min,
next: () => 0,
nextBool: () => true,
nextRange: (min: number, _max: number) => min,
nextRangeInt: (min: number, _max: number) => min,
nextFloat1: () => 0,
},
},
},
]);
expect(getEquippedItemInstance(world.getGeneral(1)!, 'item')).toBeNull();
expect(world.getGeneral(1)!.role.items.item).toBeNull();
// 5. Agitate (G1 -> C2)
reEquipStrategyItem('che_계략_이추');
const agitateDef = agitateSpec.createDefinition(strategyEnv);
await runner.runTurn([
{
generalId: 1,
@@ -455,9 +508,12 @@ describe('General Commands New Scenario', () => {
const trust = c2_after_agitate.meta.trust as number;
expect(trust).toBeLessThan(100);
expect(c2_after_agitate.security).toBeLessThan(1000);
expect(getEquippedItemInstance(world.getGeneral(1)!, 'item')).toBeNull();
// 5. Seize (G1 -> C2)
const seizeDef = seizeSpec.createDefinition(systemEnv);
// 6. Seize (G1 -> C2)
reEquipStrategyItem('che_계략_향낭');
const seizeDef = seizeSpec.createDefinition(strategyEnv);
const goldBeforeSeize = world.getGeneral(1)!.gold;
// Ensure C2 is supplied (nation has gold/rice)
await runner.runTurn([
{
@@ -474,6 +530,7 @@ describe('General Commands New Scenario', () => {
const g1_after_seize = world.getGeneral(1)!;
expect(g1_after_seize.gold).toBeGreaterThan(4000); // Cost deducted multiple times
expect(g1_after_seize.gold).toBeGreaterThan(goldBeforeSeize - systemEnv.develCost);
expect(getEquippedItemInstance(g1_after_seize, 'item')).toBeNull();
});
});
+72
View File
@@ -11,6 +11,13 @@ import type { WarEngineConfig } from '../src/war/types.js';
import { WarCrewType } from '../src/war/crewType.js';
import { loadWarTriggerModules } from '../src/war/triggers/index.js';
import { WarUnitCity, WarUnitGeneral } from '../src/war/units.js';
import {
createItemActionModules,
createItemModuleRegistry,
equipNewItem,
getEquippedItemInstance,
loadItemModules,
} from '../src/items/index.js';
const buildConfig = (): WarEngineConfig => ({
armPerPhase: 500,
@@ -209,6 +216,71 @@ describe('war triggers', () => {
});
describe('resolveWarBattle', () => {
it('persists multi-use battle item charges and removes the last charge', async () => {
const general = buildGeneral(100);
equipNewItem(general, 'item', 'event_충차', { charges: 2 });
const itemModules = createItemActionModules(
createItemModuleRegistry(await loadItemModules(['event_충차']))
).war;
for (const expectedCharges of [1, null] as const) {
general.crew = 5000;
general.rice = 10000;
const defenderCity = { ...buildCity(), wall: 3000, wallMax: 3000 };
resolveWarBattle({
rng: new RandUtil(new ConstantRNG(0)),
unitSet: buildUnitSet(),
config: buildConfig(),
time: { year: 200, month: 1, startYear: 180 },
attacker: {
general,
city: buildCity(),
nation: buildNation(),
modules: itemModules,
},
defenders: [],
defenderCity,
defenderNation: buildNation(),
});
const equipped = getEquippedItemInstance(general, 'item');
if (expectedCharges === null) {
expect(equipped).toBeNull();
expect(general.role.items.item).toBeNull();
} else {
expect(equipped?.state.charges).toBe(expectedCharges);
expect(general.role.items.item).toBe('event_충차');
}
}
});
it('removes a one-use battle item through the canonical inventory', async () => {
const general = buildGeneral(100);
equipNewItem(general, 'item', 'che_저격_수극');
const itemModules = createItemActionModules(
createItemModuleRegistry(await loadItemModules(['che_저격_수극']))
).war;
resolveWarBattle({
rng: new RandUtil(new ConstantRNG(0)),
unitSet: buildUnitSet(),
config: buildConfig(),
time: { year: 200, month: 1, startYear: 180 },
attacker: {
general,
city: buildCity(),
nation: buildNation(),
modules: itemModules,
},
defenders: [],
defenderCity: buildCity(),
defenderNation: buildNation(),
});
expect(getEquippedItemInstance(general, 'item')).toBeNull();
expect(general.role.items.item).toBeNull();
});
it('handles supply rout when defender nation has no rice', () => {
const rng = new RandUtil(new ConstantRNG(0));
const config = buildConfig();