314 lines
9.7 KiB
TypeScript
314 lines
9.7 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
recruitsRevivalAllowance,
|
|
type RecruitsRevivalFacts,
|
|
} from '../game/recruits';
|
|
import {
|
|
eliteRevivalRemaining,
|
|
forceRevivalLimit,
|
|
forceRevivalPrice,
|
|
newRevivalRules,
|
|
} from '../game/revival';
|
|
import { FACTIONS } from '../game/catalog';
|
|
|
|
// GF9 Ecaz & Moritani pp11,16: double CURRENT rate, ordinary limit7;
|
|
// low-threshold Fremen4 doubles but is capped7. Other policy inputs below
|
|
// are supplied facts, not a resolution of the open questions in
|
|
// docs/RECRUITS_RULES.md (late paid revivals, pending quotes, unlimited caps).
|
|
// https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf
|
|
const active = { turn: 4, recruits: { turn: 4 } } as const;
|
|
const facts = (
|
|
overrides: Partial<RecruitsRevivalFacts> = {},
|
|
): RecruitsRevivalFacts => ({
|
|
currentFreeRate: 2,
|
|
currentLimit: 3,
|
|
revived: 0,
|
|
freeAllowanceUsed: 0,
|
|
freeRateCap: 7,
|
|
...overrides,
|
|
});
|
|
|
|
void test('Recruits doubles every current ordinary rate and raises three/five-force totals to seven', () => {
|
|
for (const faction of FACTIONS) {
|
|
const baseLimit = forceRevivalLimit(
|
|
{},
|
|
{ id: faction.id, faction: faction.id },
|
|
);
|
|
const allowance = recruitsRevivalAllowance(
|
|
active,
|
|
facts({ currentFreeRate: faction.revival, currentLimit: baseLimit }),
|
|
);
|
|
assert.equal(
|
|
allowance.freeRate,
|
|
Math.min(faction.revival * 2, 7),
|
|
faction.id,
|
|
);
|
|
assert.equal(
|
|
allowance.limit,
|
|
['choam', 'tleilaxu'].includes(faction.id) ? 20 : 7,
|
|
);
|
|
assert.equal(allowance.remaining, allowance.limit);
|
|
assert.equal(allowance.freeRemaining, allowance.freeRate);
|
|
}
|
|
assert.equal(
|
|
recruitsRevivalAllowance(active, facts({ currentLimit: 5 })).limit,
|
|
7,
|
|
);
|
|
});
|
|
|
|
void test('the official current-rate Fremen example is four doubled and capped at seven', () => {
|
|
assert.deepEqual(
|
|
recruitsRevivalAllowance(active, facts({ currentFreeRate: 4 })),
|
|
{
|
|
active: true,
|
|
freeRate: 7,
|
|
limit: 7,
|
|
remaining: 7,
|
|
freeRemaining: 7,
|
|
},
|
|
);
|
|
// A settled ally grant/current rate is input; printed faction metadata is
|
|
// intentionally not consulted or permanently changed by this calculator.
|
|
assert.equal(
|
|
recruitsRevivalAllowance(active, facts({ currentFreeRate: 3 })).freeRate,
|
|
6,
|
|
);
|
|
});
|
|
|
|
void test('prior total revivals and explicitly resolved free usage consume different ledgers', () => {
|
|
const base = facts({ revived: 3, freeAllowanceUsed: 2 });
|
|
const freeUsed = recruitsRevivalAllowance(active, base);
|
|
assert.equal(freeUsed.remaining, 4);
|
|
assert.equal(freeUsed.freeRemaining, 2);
|
|
// The caller can instead supply total-revival usage if that is the chosen
|
|
// late-play ruling. The helper neither assumes it nor refunds the paid unit.
|
|
const totalUsed = recruitsRevivalAllowance(active, {
|
|
...base,
|
|
freeAllowanceUsed: 3,
|
|
});
|
|
assert.equal(totalUsed.remaining, 4);
|
|
assert.equal(totalUsed.freeRemaining, 1);
|
|
const exhausted = recruitsRevivalAllowance(
|
|
active,
|
|
facts({ revived: 8, freeAllowanceUsed: 9 }),
|
|
);
|
|
assert.equal(exhausted.remaining, 0);
|
|
assert.equal(exhausted.freeRemaining, 0);
|
|
assert.equal(
|
|
recruitsRevivalAllowance(
|
|
active,
|
|
facts({ revived: 6, freeAllowanceUsed: 0 }),
|
|
).freeRemaining,
|
|
1,
|
|
'unspent free entitlement cannot exceed the remaining physical limit',
|
|
);
|
|
});
|
|
|
|
void test('turn stamps expire in either direction and repeated inspection never compounds the rate', () => {
|
|
const input = Object.freeze(facts());
|
|
const scope = Object.freeze({
|
|
turn: 4,
|
|
recruits: Object.freeze({ turn: 4 }),
|
|
});
|
|
assert.deepEqual(
|
|
recruitsRevivalAllowance(scope, input),
|
|
recruitsRevivalAllowance(scope, input),
|
|
);
|
|
for (const scope of [
|
|
{ turn: 4 },
|
|
{ turn: 4, recruits: null },
|
|
{ turn: 5, recruits: { turn: 4 } },
|
|
{ turn: 3, recruits: { turn: 4 } },
|
|
]) {
|
|
const result = recruitsRevivalAllowance(scope, input);
|
|
assert.equal(result.active, false);
|
|
assert.equal(result.freeRate, 2);
|
|
assert.equal(result.limit, 3);
|
|
}
|
|
assert.equal(input.currentFreeRate, 2);
|
|
});
|
|
|
|
void test('unlimited physical allowances remain twenty and their free caps are explicit resolved inputs', () => {
|
|
const rules = { revivalRules: newRevivalRules() };
|
|
for (const faction of ['choam', 'tleilaxu'] as const) {
|
|
const limit = forceRevivalLimit(rules, { id: faction, faction });
|
|
assert.equal(limit, 20);
|
|
const result = recruitsRevivalAllowance(
|
|
active,
|
|
facts({
|
|
currentLimit: limit,
|
|
currentFreeRate: faction === 'choam' ? 0 : 2,
|
|
revived: 8,
|
|
freeAllowanceUsed: 4,
|
|
}),
|
|
);
|
|
assert.equal(result.limit, 20);
|
|
assert.equal(result.remaining, 12);
|
|
assert.equal(result.freeRemaining, 0);
|
|
}
|
|
// This hypothetical is intentionally caller-resolved; no global seven-free
|
|
// rule is invented for a faction whose ordinary total cap does not apply.
|
|
for (const freeRateCap of [7, 8])
|
|
assert.equal(
|
|
recruitsRevivalAllowance(
|
|
active,
|
|
facts({ currentFreeRate: 4, currentLimit: 20, freeRateCap }),
|
|
).freeRate,
|
|
freeRateCap,
|
|
);
|
|
});
|
|
|
|
void test('resolved cancellation facts affect the faction advantage, while free/normal prevention never grants units', () => {
|
|
const rules = {
|
|
revivalRules: {
|
|
...newRevivalRules(),
|
|
choamBlocked: true,
|
|
limitBlocked: true,
|
|
},
|
|
};
|
|
for (const faction of ['choam', 'tleilaxu'] as const) {
|
|
const currentLimit = forceRevivalLimit(rules, { id: faction, faction });
|
|
assert.equal(currentLimit, 3);
|
|
const result = recruitsRevivalAllowance(
|
|
active,
|
|
facts({ currentLimit, freeBlocked: true }),
|
|
);
|
|
assert.equal(
|
|
result.limit,
|
|
7,
|
|
'independent ordinary card raise, given these resolved facts',
|
|
);
|
|
assert.equal(result.freeRate, 0);
|
|
assert.equal(result.freeRemaining, 0);
|
|
assert.equal(result.remaining, 7);
|
|
}
|
|
for (const recruits of [undefined, { turn: 4 }]) {
|
|
const result = recruitsRevivalAllowance(
|
|
{ turn: 4, recruits },
|
|
facts({ revivalBlocked: true }),
|
|
);
|
|
assert.equal(result.remaining, 0);
|
|
assert.equal(result.freeRemaining, 0);
|
|
}
|
|
});
|
|
|
|
void test('doubling free allowance does not make paid units free or change CHOAM/Tleilaxu prices', () => {
|
|
const ordinary = recruitsRevivalAllowance(active, facts());
|
|
assert.deepEqual(
|
|
forceRevivalPrice('atreides', 7, 0, ordinary.freeRemaining, false),
|
|
{ free: 4, normalCost: 6, cost: 6 },
|
|
);
|
|
const tleilaxu = recruitsRevivalAllowance(
|
|
active,
|
|
facts({ currentLimit: 20 }),
|
|
);
|
|
assert.deepEqual(
|
|
forceRevivalPrice('tleilaxu', 10, 0, tleilaxu.freeRemaining, true),
|
|
{ free: 4, normalCost: 12, cost: 6 },
|
|
);
|
|
const choam = recruitsRevivalAllowance(
|
|
active,
|
|
facts({ currentFreeRate: 0, currentLimit: 20 }),
|
|
);
|
|
assert.deepEqual(
|
|
forceRevivalPrice('choam', 7, 0, choam.freeRemaining, false),
|
|
{ free: 0, normalCost: 14, cost: 7 },
|
|
);
|
|
assert.deepEqual(
|
|
forceRevivalPrice(
|
|
'choam',
|
|
7,
|
|
0,
|
|
choam.freeRemaining,
|
|
false,
|
|
undefined,
|
|
true,
|
|
),
|
|
{ free: 0, normalCost: 14, cost: 14 },
|
|
);
|
|
});
|
|
|
|
void test('Ixian free-cyborg selection retains physical quantities and the existing surcharge', () => {
|
|
const result = recruitsRevivalAllowance(
|
|
active,
|
|
facts({ currentFreeRate: 1 }),
|
|
);
|
|
assert.equal(result.freeRemaining, 2);
|
|
// Four physical units, two Cyborgs. A free Cyborg is one free unit, not two.
|
|
assert.deepEqual(
|
|
forceRevivalPrice('ixians', 4, 2, result.freeRemaining, false, 1),
|
|
{ free: 2, normalCost: 5, cost: 5 },
|
|
);
|
|
assert.deepEqual(
|
|
forceRevivalPrice('ixians', 4, 2, result.freeRemaining, false, 2),
|
|
{ free: 2, normalCost: 4, cost: 4 },
|
|
);
|
|
assert.equal(
|
|
result.remaining,
|
|
7,
|
|
'pricing has not consumed or doubled physical allowance',
|
|
);
|
|
});
|
|
|
|
void test('Recruits does not bypass per-turn Fedaykin/Sardaukar revival restrictions or invent Ixian stock', () => {
|
|
for (const faction of ['fremen', 'emperor', 'ixians'] as const) {
|
|
const p = {
|
|
faction,
|
|
elites: { tanks: 2, reserves: 0, forces: {}, revived: 1 },
|
|
};
|
|
const before = structuredClone(p);
|
|
const result = recruitsRevivalAllowance(
|
|
active,
|
|
facts({
|
|
currentFreeRate: faction === 'fremen' ? 3 : 1,
|
|
revived: 1,
|
|
freeAllowanceUsed: 1,
|
|
}),
|
|
);
|
|
assert.equal(result.remaining, 6);
|
|
assert.equal(eliteRevivalRemaining(p), faction === 'ixians' ? 2 : 0);
|
|
assert.deepEqual(p, before);
|
|
}
|
|
});
|
|
|
|
void test('calculation is JSON-stable, mutation-free and cannot spend spice, revise pending prices or draw RNG', (t) => {
|
|
const state = {
|
|
...active,
|
|
spice: 12,
|
|
pendingRevival: { cost: 4, free: 2, amount: 4 },
|
|
};
|
|
const input = facts({ revived: 2, freeAllowanceUsed: 2 });
|
|
const before = JSON.stringify({ state, input });
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw new Error('No RNG allowed');
|
|
});
|
|
const a = recruitsRevivalAllowance(state, input);
|
|
const roundTrip = JSON.parse(before);
|
|
assert.deepEqual(
|
|
a,
|
|
recruitsRevivalAllowance(roundTrip.state, roundTrip.input),
|
|
);
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(JSON.stringify({ state, input }), before);
|
|
});
|
|
|
|
void test('invalid or missing force-count facts cannot silently choose an unresolved policy or return NaN', () => {
|
|
for (const key of [
|
|
'currentFreeRate',
|
|
'currentLimit',
|
|
'revived',
|
|
'freeAllowanceUsed',
|
|
'freeRateCap',
|
|
] as const)
|
|
for (const bad of [-1, 1.5, NaN, Infinity, undefined])
|
|
assert.throws(
|
|
() =>
|
|
recruitsRevivalAllowance(active, {
|
|
...facts(),
|
|
[key]: bad,
|
|
} as RecruitsRevivalFacts),
|
|
RangeError,
|
|
);
|
|
});
|