Files
core2026/packages/logic/test/specialTraitSelectorParity.test.ts
T

131 lines
4.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { RandUtil, type RNG } from '@sammo-ts/common';
import { TraitRequirement, TraitWeightType } from '../src/triggers/special/requirements.js';
import { TraitSelector } from '../src/triggers/special/selector.js';
import type { TraitModule } from '../src/triggers/special/types.js';
class ScriptedRng implements RNG {
public floatCalls = 0;
public intCalls = 0;
constructor(
private readonly floats: number[],
private readonly ints: number[]
) {}
getMaxInt(): number {
return 0x7fff_ffff;
}
nextBytes(bytes: number): Uint8Array<ArrayBuffer> {
return new Uint8Array(bytes);
}
nextBits(bits: number): Uint8Array<ArrayBuffer> {
return new Uint8Array(Math.ceil(bits / 8));
}
nextInt(max = 0x7fff_ffff): number {
const value = this.ints[this.intCalls++] ?? 0;
return Math.max(0, Math.min(value, max));
}
nextFloat1(): number {
return this.floats[this.floatCalls++] ?? 0;
}
}
const scenarioStat = {
total: 300,
min: 10,
max: 100,
npcTotal: 150,
npcMax: 75,
npcMin: 10,
chiefMin: 70,
};
const trait = (
key: string,
requirement: TraitRequirement,
weightType = TraitWeightType.NORM,
weight = 1
): TraitModule =>
({
key,
name: key,
info: '',
kind: 'domestic',
selection: { requirements: [requirement], weightType, weight },
}) as TraitModule;
describe('legacy speciality selector parity', () => {
it('sets positive and negative stat bits in the legacy order', () => {
expect(
TraitSelector.calcCondGeneric(
{ leadership: 80, strength: 75, intelligence: 40 },
scenarioStat
)
).toBe(
TraitRequirement.STAT_LEADERSHIP |
TraitRequirement.STAT_STRENGTH |
TraitRequirement.STAT_NOT_INTEL
);
expect(
TraitSelector.calcCondGeneric(
{ leadership: 70, strength: 70, intelligence: 70 },
scenarioStat
)
).toBe(TraitRequirement.STAT_STRENGTH);
});
it('rounds the dex threshold and still consumes a value choice when every dex is zero', () => {
const source = new ScriptedRng([0.9], [99, 4]);
const result = TraitSelector.calcCondDexterity(new RandUtil(source), [0, 0, 0, 0, 0]);
expect(result).toBe(0);
expect(source.floatCalls).toBe(1);
expect(source.intCalls).toBe(2);
});
it('uses one weighted absolute draw with a relative sentinel before the norm pool', () => {
const source = new ScriptedRng([0.5, 0.1], []);
const result = TraitSelector.pickDomesticTrait(
new RandUtil(source),
{ leadership: 40, strength: 40, intelligence: 80 },
[
trait('rare', TraitRequirement.STAT_INTEL, TraitWeightType.PERCENT, 2.5),
trait('normal-a', TraitRequirement.STAT_INTEL),
trait('normal-b', TraitRequirement.STAT_INTEL),
],
[],
scenarioStat
);
expect(result).toBe('normal-a');
expect(source.floatCalls).toBe(2);
});
it('retries without previous traits and reruns the war dex RNG path', () => {
const source = new ScriptedRng([0.9, 0.9, 0.1], [99, 0, 99, 0]);
const result = TraitSelector.pickWarTrait(
new RandUtil(source),
{ leadership: 80, strength: 75, intelligence: 40 },
[200, 10, 10, 10, 10],
[
trait(
'footman',
(TraitRequirement.STAT_LEADERSHIP |
TraitRequirement.REQ_DEXTERITY |
TraitRequirement.ARMY_FOOTMAN |
TraitRequirement.STAT_NOT_INTEL) as TraitRequirement
),
],
['footman'],
scenarioStat
);
expect(result).toBe('footman');
expect(source.floatCalls).toBe(3);
expect(source.intCalls).toBe(4);
});
});