Connect physical Jacurutu allocation and cumulative station choices

This commit is contained in:
Quintine committed 2026-10-14 14:49:07 +11:00
1 parent 373badd560
commit 554f2bf9e9
22 files changed
+1943 -129

No files matched your search

+186 -7
View File
@@ -11,10 +11,13 @@ import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { discoveryStormActions } from '../game/discovery-storm-options';
import {
createDiscoveryStorm,
createStormSource,
discoveryStormDistance,
discoveryStormOffer,
discoveryStormSignature,
} from '../game/discovery-storm';
import { fighterCount } from '../game/advisors';
import {
discoveryStormFixture,
confirmDiscoveryStorm,
@@ -90,25 +93,20 @@ void test('Weather Control replaces natural provenance and bypasses the station,
g = applyAction(g, 'a', { type: 'card', card: card.id, amount });
assert.equal(g.stormMovementSource?.kind, 'weather');
assert.deepEqual(g.ready, []);
assert.match(discoveryStormOffer(g)!.blocked!, /Weather Control/);
g = confirmDiscoveryStorm(reload(g));
assert.equal(g.ecologicalStorm, undefined);
assert.equal(g.storm, 6 + amount);
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
}
});
void test('unoccupied, contested and legacy unknown-source storms continue without inventing entitlement or changing distance', () => {
for (const mode of ['empty', 'contested', 'legacy']) {
void test('unoccupied and legacy unknown-source storms continue without inventing entitlement or changing distance', () => {
for (const mode of ['empty', 'legacy']) {
let g = discoveryStormFixture();
if (mode === 'empty') {
g.players[0].reserves +=
g.players[0].forces['ecological-testing-station:0'];
delete g.players[0].forces['ecological-testing-station:0'];
}
if (mode === 'contested') {
g.players[1].reserves--;
g.players[1].forces['ecological-testing-station:0'] = 1;
}
if (mode === 'legacy') delete g.stormMovementSource;
g = confirmDiscoveryStorm(g);
assert.equal(g.ecologicalStorm, undefined);
@@ -198,3 +196,184 @@ void test('adjusted Advanced storm retains its receipt through Fremen protection
assert.equal(g.ecologicalStorm?.stage, 'complete');
assert.equal(g.storm, 9);
});
const stationKey = 'ecological-testing-station:0';
/** A second distinct non-advisor occupant beside the actual entrant. */
function contest(g: Game, second = 'g'): Game {
const player = g.players.find((candidate) => candidate.id === second)!;
player.reserves -= 2;
player.forces[stationKey] = (player.forces[stationKey] ?? 0) + 2;
return g;
}
const occupants = (g: Game) =>
g.order.filter((id) => {
const player = g.players.find((candidate) => candidate.id === id)!;
return fighterCount(player, 'ecological-testing-station') > 0;
});
const control = (g: Game, id: string, delta: number) => {
const action = discoveryStormActions(viewGame(g, id)).find(
(candidate) => candidate.delta === delta,
);
assert.ok(action, `${id} must receive a legal ${delta} station control.`);
return action;
};
void test('two original non-advisor occupants each choose once in original storm order with one cumulative traversal', () => {
let g = confirmDiscoveryStorm(contest(discoveryStormFixture()));
const before = reload(g);
const order = occupants(g);
assert.equal(order.length, 2);
assert.notEqual(order[0], order[1]);
assert.equal(g.decision?.kind, 'ecologicalStorm');
assert.equal(g.decision?.player, order[0]);
assert.equal(g.ecologicalStorm?.owner, order[0]);
assert.deepEqual(g.ecologicalStorm?.queue, { order, choices: [] });
assert.equal(g.storm, 6);
const event = g.decision!.event;
assert.equal(viewGame(g, order[0]).ecologicalStorm?.base, 2);
assert.equal(viewGame(g, order[1]).ecologicalStorm, null);
assert.deepEqual(discoveryStormActions(viewGame(g, order[1])), []);
reject(g, order[1], control(g, order[0], 1));
reject(g, order[0], { ...control(g, order[0], 1), event: `${event}:1` });
reject(g, order[0], { ...control(g, order[0], 1), distance: 1 });
for (const delta of [-2, 2, 0.5, NaN])
reject(g, order[0], { ...control(g, order[0], 0), delta });
assert.deepEqual(g, before);
g = applyAction(reload(g), order[0], control(g, order[0], 1));
assert.equal(g.ecologicalStorm?.stage, 'choose');
assert.equal(g.ecologicalStorm?.owner, order[1]);
assert.equal(g.decision?.player, order[1]);
assert.ok(g.decision?.kind === 'ecologicalStorm');
assert.equal(g.decision?.event, `${event}:1`);
assert.equal(discoveryStormDistance(g.ecologicalStorm!), 3);
assert.equal(g.stormPending, 3);
assert.equal(g.storm, 6);
assert.equal(g.phase, 0);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
reject(g, order[0], control(before, order[0], 1));
reject(g, order[1], { ...control(g, order[1], 0), event });
const second = viewGame(g, order[1]).ecologicalStorm!;
assert.deepEqual(second.options.map((option) => option.distance), [2, 3, 4]);
assert.equal(viewGame(g, order[0]).ecologicalStorm, null);
g = applyAction(reload(g), order[1], control(g, order[1], -1));
assert.equal(g.ecologicalStorm?.stage, 'complete');
assert.deepEqual(g.ecologicalStorm?.queue?.choices, [
{ owner: order[0], delta: 1 },
{ owner: order[1], delta: -1 },
]);
assert.equal(g.storm, 8);
assert.equal(g.phase, 1);
assert.equal(g.stormMovementSource, undefined);
assert.equal(g.stormResolution, null);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
reject(g, order[1], { type: 'decision', event: `${event}:1`, delta: 0 });
reject(g, order[0], { type: 'decision', event, delta: 1 });
});
void test('a contested station shares one cumulative bound: two decreases reach zero and opposite choices cancel', () => {
let g = contest(discoveryStormFixture());
g.stormPending = 3;
g.stormMovementSource = createStormSource(g.turn, 'dials', 3);
g = confirmDiscoveryStorm(g);
const order = occupants(g);
let floored = applyAction(reload(g), order[0], control(g, order[0], -1));
assert.equal(discoveryStormDistance(floored.ecologicalStorm!), 2);
assert.deepEqual(
discoveryStormOffer(floored, floored.ecologicalStorm!)!.options.map(
(option) => option.distance,
),
[1, 2, 3],
);
floored = applyAction(reload(floored), order[1], control(floored, order[1], -1));
assert.equal(discoveryStormDistance(floored.ecologicalStorm!), 1);
const single = contest(discoveryStormFixture());
single.stormPending = 1;
single.stormMovementSource = createStormSource(single.turn, 'dials', 1);
let one = confirmDiscoveryStorm(single);
const oneOrder = occupants(one);
one = applyAction(reload(one), oneOrder[0], control(one, oneOrder[0], -1));
assert.deepEqual(
discoveryStormOffer(one, one.ecologicalStorm!)!.options.map(
(option) => option.delta,
),
[0, 1],
);
reject(one, oneOrder[1], {
type: 'decision',
event: one.ecologicalStorm!.event,
delta: -1,
});
one = applyAction(reload(one), oneOrder[1], control(one, oneOrder[1], 0));
assert.equal(discoveryStormDistance(one.ecologicalStorm!), 0);
assert.equal(one.storm, 6);
assert.equal(one.phase, 1);
const ceiling = contest(discoveryStormFixture());
ceiling.storm = 6;
ceiling.stormPending = 40;
ceiling.stormMovementSource = createStormSource(ceiling.turn, 'dials', 40);
let high = confirmDiscoveryStorm(ceiling);
const highOrder = occupants(high);
assert.deepEqual(
discoveryStormOffer(high, high.ecologicalStorm!)!.options.map(
(option) => option.delta,
),
[-1, 0],
);
reject(high, highOrder[0], {
type: 'decision',
event: high.ecologicalStorm!.event,
delta: 1,
});
high = applyAction(reload(high), highOrder[0], control(high, highOrder[0], -1));
assert.equal(discoveryStormDistance(high.ecologicalStorm!), 39);
high = applyAction(reload(high), highOrder[1], control(high, highOrder[1], 1));
assert.equal(discoveryStormDistance(high.ecologicalStorm!), 40);
assert.equal(high.storm, ((6 - 1 + 40) % 18) + 1);
});
void test('a lone Bene Gesserit advisor group is not a station occupant and never receives a contested choice', () => {
const g = contest(discoveryStormFixture());
const advisor = g.players[1];
advisor.faction = 'beneGesserit';
advisor.advisors = { 'ecological-testing-station': {} };
assert.equal(occupants(g).includes(advisor.id), false);
assert.deepEqual(occupants(g), [g.players[0].id]);
const frame = createDiscoveryStorm(g, 'advisor-exclusion');
assert.equal(frame.queue, undefined);
assert.equal(frame.owner, g.players[0].id);
assert.equal(discoveryStormOffer(g, frame)!.owner, g.players[0].id);
});
void test('Weather Control denies every occupant of a contested station', () => {
for (const advanced of [false, true]) {
let g = contest(discoveryStormFixture(advanced));
const card = holdStormCard(g, 'a', 'weather');
g = applyAction(g, 'g', { type: 'ready' });
g = applyAction(g, 'a', { type: 'card', card: card.id, amount: 2 });
assert.equal(g.stormMovementSource?.kind, 'weather');
assert.equal(discoveryStormActions(viewGame(g, occupants(g)[0])).length, 0);
g = confirmDiscoveryStorm(reload(g));
assert.equal(g.ecologicalStorm, undefined);
assert.equal(g.storm, 8);
}
});
void test('every AI level submits the second occupant’s remaining cumulative choice', () => {
const g = confirmDiscoveryStorm(contest(discoveryStormFixture(true)));
const order = occupants(g);
const committed = applyAction(
reload(g),
order[0],
control(g, order[0], 0),
);
for (const level of DIFFICULTIES) {
const view = viewGame(committed, order[1]);
view.players.find((player) => player.id === order[1])!.bot = level;
const actions = botActions(view);
assert.ok(actions.length);
for (const action of actions)
assert.doesNotThrow(() => applyAction(reload(committed), order[1], action));
}
assert.equal(committed.decision?.player, order[1]);
assert.equal(discoveryStormDistance(committed.ecologicalStorm!), 2);
});