Connect owned Sandmaster destination pile choices
This commit is contained in:
1 parent
24863480cb
commit
0040ed1921
17 files changed
+458
-89
No files matched your search
@@ -146,6 +146,19 @@ function trigger(g: Game, beneficiary = 'a', copy = false) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function sandmasterFixture(advanced = true, extra: FactionId[] = []) {
|
||||
const g = fixture('fremen', extra);
|
||||
g.advanced = advanced;
|
||||
g.expansions = ['ecaz'];
|
||||
army(g, 'a', { 'red_chasm:7': 2 });
|
||||
g.spice = { 'south_mesa:4': 4, 'south_mesa:5': 2 };
|
||||
const skills = createLeaderSkills(() => 0.2);
|
||||
skills.deck = skills.deck.filter(skill => skill !== 'sandmaster');
|
||||
skills.assignments = [{ skill: 'sandmaster', owner: 'a', leader: p(g, 'a').leaders[0].id }];
|
||||
g.leaderSkills = skills;
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('physical Fremen Ambassador relocation collects optional Sandmaster spice without consuming ordinary allowances', () => {
|
||||
for (const collect of [false, true]) {
|
||||
const start = fixture();
|
||||
@@ -171,6 +184,107 @@ void test('physical Fremen Ambassador relocation collects optional Sandmaster sp
|
||||
assert.equal(done.pendingAmbassador, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic and Advanced Ambassador relocation debit exactly the chosen destination pile or decline both', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const pending = trigger(enter(sandmasterFixture(advanced)));
|
||||
for (const selected of [undefined, 'south_mesa:4', 'south_mesa:5']) {
|
||||
const action: Action = {
|
||||
type: 'decision',
|
||||
event: pending.pendingAmbassador!.event,
|
||||
forces: { 'red_chasm:7': 2 },
|
||||
territory: 'south_mesa',
|
||||
sector: 4,
|
||||
sandmasterCollect: selected !== undefined,
|
||||
...(selected ? { sandmasterPile: selected } : {}),
|
||||
};
|
||||
const done = applyAction(reload(pending), 'a', action);
|
||||
assert.equal(p(done, 'a').forces['south_mesa:4'], 2);
|
||||
assert.equal(p(done, 'a').forces['red_chasm:7'] ?? 0, 0);
|
||||
assert.equal(p(done, 'a').spice, selected ? 21 : 20);
|
||||
assert.equal(done.spice['south_mesa:4'], selected === 'south_mesa:4' ? 3 : 4);
|
||||
assert.equal(done.spice['south_mesa:5'], selected === 'south_mesa:5' ? 1 : 2);
|
||||
assert.equal(p(done, 'a').moved, 0);
|
||||
assert.equal(p(done, 'a').shipped, false);
|
||||
assert.equal(p(done, 'a').reserves, 18);
|
||||
assert.equal(done.pendingAmbassador, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('Ambassador collecting declarations reject missing, malformed, foreign and exhausted pile keys before mutation', () => {
|
||||
const start = sandmasterFixture();
|
||||
start.spice['red_chasm:7'] = 3;
|
||||
const pending = trigger(enter(start));
|
||||
const action: Action = {
|
||||
type: 'decision',
|
||||
event: pending.pendingAmbassador!.event,
|
||||
forces: { 'red_chasm:7': 2 },
|
||||
territory: 'south_mesa',
|
||||
sector: 4,
|
||||
sandmasterCollect: true,
|
||||
};
|
||||
for (const key of [undefined, '', 'south_mesa:999', 'red_chasm:7', 5, null, ['south_mesa:5']])
|
||||
reject(pending, { ...action, sandmasterPile: key } as Action);
|
||||
reject(pending, { ...action, sandmasterCollect: false, sandmasterPile: 'south_mesa:5' });
|
||||
const exhausted = reload(pending);
|
||||
exhausted.spice['south_mesa:5'] = 0;
|
||||
reject(exhausted, { ...action, sandmasterPile: 'south_mesa:5' });
|
||||
const dead = reload(pending);
|
||||
p(dead, 'a').leaders[0].dead = true;
|
||||
reject(dead, { ...action, sandmasterPile: 'south_mesa:5' });
|
||||
const captured = reload(pending);
|
||||
p(captured, 'a').leaders[0].capturedBy = 'in';
|
||||
reject(captured, { ...action, sandmasterPile: 'south_mesa:5' });
|
||||
});
|
||||
|
||||
void test('selected Ambassador collection commits before Intrusion and a restored arrival resumes without a second debit', () => {
|
||||
const start = sandmasterFixture(true, ['beneGesserit']);
|
||||
army(start, 'beneGesserit', { 'south_mesa:4': 1 });
|
||||
let g = trigger(enter(start));
|
||||
const event = g.pendingAmbassador!.event;
|
||||
g = move(g, {
|
||||
forces: { 'red_chasm:7': 2 },
|
||||
territory: 'south_mesa',
|
||||
sector: 4,
|
||||
sandmasterCollect: true,
|
||||
sandmasterPile: 'south_mesa:5',
|
||||
});
|
||||
assert.equal(g.decision?.kind, 'intrusion');
|
||||
assert.equal(g.pendingAmbassador?.stage, 'arrival');
|
||||
assert.equal(g.pendingAmbassador?.event, event);
|
||||
assert.equal(p(g, 'a').forces['south_mesa:4'], 2);
|
||||
assert.equal(p(g, 'a').spice, 21);
|
||||
assert.equal(g.spice['south_mesa:4'], 4);
|
||||
assert.equal(g.spice['south_mesa:5'], 1);
|
||||
g = applyAction(reload(g), 'beneGesserit', { type: 'decision', accept: true });
|
||||
g = allow(reload(g));
|
||||
assert.equal(p(g, 'a').forces['south_mesa:4'], 2);
|
||||
assert.equal(p(g, 'a').spice, 21);
|
||||
assert.equal(g.spice['south_mesa:4'], 4);
|
||||
assert.equal(g.spice['south_mesa:5'], 1);
|
||||
assert.equal(g.log.filter(entry => entry.automatic?.name === 'Sandmaster collection').length, 1);
|
||||
settled(g);
|
||||
});
|
||||
|
||||
void test('Ambassador sector relocation within its source territory earns no Sandmaster spice', () => {
|
||||
const start = sandmasterFixture();
|
||||
army(start, 'a', { 'wind_pass:14': 2 });
|
||||
start.spice = { 'wind_pass:14': 4, 'wind_pass:15': 2 };
|
||||
const pending = trigger(enter(start));
|
||||
const action: Action = {
|
||||
type: 'decision',
|
||||
event: pending.pendingAmbassador!.event,
|
||||
forces: { 'wind_pass:14': 2 },
|
||||
territory: 'wind_pass',
|
||||
sector: 15,
|
||||
};
|
||||
reject(pending, { ...action, sandmasterCollect: true, sandmasterPile: 'wind_pass:15' });
|
||||
const done = applyAction(reload(pending), 'a', action);
|
||||
assert.equal(p(done, 'a').forces['wind_pass:15'], 2);
|
||||
assert.equal(p(done, 'a').spice, 20);
|
||||
assert.deepEqual(done.spice, start.spice);
|
||||
});
|
||||
function move(g: Game, extra: Omit<Action, 'type'>, actor = 'a') {
|
||||
return applyAction(g, actor, {
|
||||
type: 'decision',
|
||||
|
||||
@@ -7,18 +7,14 @@ import { sandmasterWormCollection } from '../game/sandmaster-worm';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { sandmasterWormGame } from './fixture-sandmaster-worm';
|
||||
|
||||
void test('the ride control shows optional destination collection only for its eligible owner and preserves opt-out', () => {
|
||||
void test('worm collection controls are restricted to the eligible owner and disappear when blocked', () => {
|
||||
const g = sandmasterWormGame();
|
||||
const quote = sandmasterWormCollection(viewGame(g, 'p'), 'p', 'red_chasm', 7);
|
||||
const html = (collect: boolean) => renderToStaticMarkup(createElement(SandmasterWormChoice, { quote, collect, onChange() {} }));
|
||||
assert.match(html(true), /checked=""/);
|
||||
assert.doesNotMatch(html(false), /checked=""/);
|
||||
assert.equal(renderToStaticMarkup(createElement(SandmasterWormChoice, {
|
||||
quote: sandmasterWormCollection(viewGame(g, 'h'), 'h', 'red_chasm', 7), collect: true, onChange() {},
|
||||
})), '');
|
||||
g.spice['red_chasm:7'] = 0;
|
||||
const blocked = renderToStaticMarkup(createElement(SandmasterWormChoice, {
|
||||
quote: sandmasterWormCollection(viewGame(g, 'p'), 'p', 'red_chasm', 7), collect: true, onChange() {},
|
||||
const render = (player: string) => renderToStaticMarkup(createElement(SandmasterWormChoice, {
|
||||
quote: sandmasterWormCollection(viewGame(g, player), player, 'red_chasm', 7),
|
||||
collect: true, onChange() {}, onPileChange() {},
|
||||
}));
|
||||
assert.doesNotMatch(blocked, /type="checkbox"/);
|
||||
assert.match(render('p'), /type="checkbox"/);
|
||||
assert.equal(render('h'), '');
|
||||
g.spice['red_chasm:7'] = 0;
|
||||
assert.doesNotMatch(render('p'), /<(?:input|select)\b/);
|
||||
});
|
||||
@@ -36,7 +36,7 @@ void test('Sandmaster collects once at a worm destination for multiple source se
|
||||
reject(next, action);
|
||||
}
|
||||
});
|
||||
void test('optional decline, legacy actions and absent or ambiguous piles keep ordinary worm riding available', () => {
|
||||
void test('optional decline and legacy actions keep ordinary worm riding available with or without spice', () => {
|
||||
for (const amount of [0, 1, 2]) for (const collect of [false, undefined]) {
|
||||
const g = sandmasterWormGame();
|
||||
g.spice['red_chasm:7'] = amount;
|
||||
@@ -50,14 +50,52 @@ void test('optional decline, legacy actions and absent or ambiguous piles keep o
|
||||
const empty = sandmasterWormGame(); empty.spice['red_chasm:7'] = 0;
|
||||
assert.match(sandmasterWormCollection(empty, 'p', 'red_chasm', 7)!.blocked!, /no spice/);
|
||||
reject(empty, sandmasterRide());
|
||||
const multiple = sandmasterWormGame();
|
||||
multiple.spice = { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 };
|
||||
const ambiguous: Action = { ...sandmasterRide(), territory: 'pasty_mesa', sector: 5 };
|
||||
assert.match(sandmasterWormCollection(multiple, 'p', 'pasty_mesa', 5)!.blocked!, /multiple spice piles/);
|
||||
reject(multiple, ambiguous);
|
||||
delete ambiguous.eliteForces;
|
||||
const next = applyAction(multiple, 'p', { ...ambiguous, sandmasterCollect: false });
|
||||
});
|
||||
void test('a selected destination pile supplies only one spice in Basic and Advanced worm rides', () => {
|
||||
for (const advanced of [false, true]) for (const key of ['pasty_mesa:5', 'pasty_mesa:6']) {
|
||||
const g = sandmasterWormGame(advanced);
|
||||
g.spice = { 'wind_pass:14': 7, 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 };
|
||||
const action: Action = { ...sandmasterRide(), territory: 'pasty_mesa', sector: 5, sandmasterPile: key };
|
||||
if (!advanced) delete action.eliteForces;
|
||||
const next = applyAction(JSON.parse(JSON.stringify(g)), 'p', action);
|
||||
assert.equal(next.players[0].spice, 6);
|
||||
assert.equal(next.spice[key], g.spice[key] - 1);
|
||||
const other = key === 'pasty_mesa:5' ? 'pasty_mesa:6' : 'pasty_mesa:5';
|
||||
assert.equal(next.spice[other], g.spice[other]);
|
||||
assert.equal(next.spice['wind_pass:14'], 7);
|
||||
assert.equal(next.players[0].forces['pasty_mesa:5'], 4);
|
||||
if (advanced) assert.equal(next.players[0].elites!.forces['pasty_mesa:5'], 2);
|
||||
assert.equal(next.players[0].moved, 1);
|
||||
assert.equal(next.log.filter(l => l.automatic?.name === 'Sandmaster collection').length, 1);
|
||||
conserved(next);
|
||||
}
|
||||
});
|
||||
void test('ambiguous collection needs an exact positive destination pile, but decline still rides', () => {
|
||||
const g = sandmasterWormGame();
|
||||
g.spice = { 'wind_pass:14': 7, 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 };
|
||||
const action: Action = { ...sandmasterRide(), territory: 'pasty_mesa', sector: 5 };
|
||||
delete action.eliteForces;
|
||||
reject(g, action);
|
||||
for (const key of ['wind_pass:14', 'pasty_mesa:9', '', 6])
|
||||
reject(g, { ...action, sandmasterPile: key });
|
||||
const emptied = structuredClone(g); emptied.spice['pasty_mesa:6'] = 0;
|
||||
reject(emptied, { ...action, sandmasterPile: 'pasty_mesa:6' });
|
||||
const next = applyAction(g, 'p', { ...action, sandmasterCollect: false });
|
||||
assert.equal(next.players[0].spice, 5);
|
||||
assert.deepEqual(next.spice, g.spice);
|
||||
assert.equal(next.players[0].forces['pasty_mesa:5'], 4);
|
||||
conserved(next);
|
||||
});
|
||||
void test('destination quoting requires current native ride ownership and phase even with a selected pile', () => {
|
||||
const g = sandmasterWormGame();
|
||||
g.spice = { 'pasty_mesa:5': 2, 'pasty_mesa:6': 3 };
|
||||
assert.equal(sandmasterWormCollection(g, 'p', 'pasty_mesa', 5, null, 'pasty_mesa:6'), null);
|
||||
assert.equal(sandmasterWormCollection(g, 'p', 'pasty_mesa', 5,
|
||||
{ kind: 'wormRide', player: 'h', territory: 'wind_pass' }, 'pasty_mesa:6'), null);
|
||||
const wrongPhase = structuredClone(g); wrongPhase.phase = 5;
|
||||
assert.equal(sandmasterWormCollection(wrongPhase, 'p', 'pasty_mesa', 5, g.decision, 'pasty_mesa:6'), null);
|
||||
const nonNative = structuredClone(g); nonNative.players[0].faction = 'guild';
|
||||
assert.equal(sandmasterWormCollection(nonNative, 'p', 'pasty_mesa', 5, g.decision, 'pasty_mesa:6'), null);
|
||||
});
|
||||
void test('failed force selection, storm entry and collecting without a ride cannot debit spice', () => {
|
||||
const g = sandmasterWormGame(true);
|
||||
@@ -77,6 +115,8 @@ void test('Sandmaster requires the owned living native trainer and the supported
|
||||
(g: typeof view) => { g.leaderSkills!.assignments.find(a => a.owner === 'p')!.faceUp = false; },
|
||||
(g: typeof view) => { g.leaderSkills!.assignments.find(a => a.owner === 'p')!.controller = 'h'; },
|
||||
(g: typeof view) => { g.players[0].leaders.find(l => l.id === g.leaderSkills!.assignments.find(a => a.owner === 'p')!.leader)!.dead = true; },
|
||||
(g: typeof view) => { g.players[0].leaders.find(l => l.id === g.leaderSkills!.assignments.find(a => a.owner === 'p')!.leader)!.capturedBy = 'h'; },
|
||||
(g: typeof view) => { g.players[0].leaders.find(l => l.id === g.leaderSkills!.assignments.find(a => a.owner === 'p')!.leader)!.gholaBy = 'h'; },
|
||||
]) {
|
||||
const bad = structuredClone(view); mutate(bad);
|
||||
assert.equal(sandmasterWormCollection(bad, 'p', 'red_chasm', 7), null);
|
||||
@@ -86,23 +126,36 @@ void test('Sandmaster requires the owned living native trainer and the supported
|
||||
(g: typeof view) => { g.players[1].faction = 'richese'; },
|
||||
]) {
|
||||
const unsupported = structuredClone(view); mutate(unsupported);
|
||||
assert.match(sandmasterWormCollection(unsupported, 'p', 'red_chasm', 7)!.blocked!, /other optional modules/);
|
||||
const quote = sandmasterWormCollection(unsupported, 'p', 'red_chasm', 7)!;
|
||||
assert.ok(quote.blocked);
|
||||
assert.deepEqual(quote.piles, []);
|
||||
}
|
||||
});
|
||||
void test('collection is committed before an interrupted BG arrival and is not repeated after JSON restoration', () => {
|
||||
const g = sandmasterWormGame(true, true);
|
||||
g.spice['red_chasm:7'] = 1;
|
||||
const pending = applyAction(g, 'p', sandmasterRide());
|
||||
assert.equal(pending.decision?.kind, 'intrusion');
|
||||
assert.equal(pending.players[0].spice, 6);
|
||||
assert.equal(pending.spice['red_chasm:7'], 0);
|
||||
const restored = normalizeAutomaticGame(JSON.parse(JSON.stringify(pending)));
|
||||
assert.deepEqual(restored, pending);
|
||||
const done = applyAction(restored, 'h', { type: 'decision', accept: false });
|
||||
assert.equal(done.players[0].spice, 6);
|
||||
assert.equal(done.log.filter(l => l.automatic?.name === 'Sandmaster collection').length, 1);
|
||||
assert.equal(done.players[0].moved, 1);
|
||||
conserved(done);
|
||||
for (const multiple of [false, true]) {
|
||||
const g = sandmasterWormGame(true, true);
|
||||
const action = sandmasterRide();
|
||||
if (multiple) {
|
||||
g.spice = { 'wind_pass:14': 7, 'pasty_mesa:5': 3, 'pasty_mesa:6': 1 };
|
||||
g.players[1].forces = { 'pasty_mesa:5': 1 };
|
||||
Object.assign(action, { territory: 'pasty_mesa', sector: 5, sandmasterPile: 'pasty_mesa:6' });
|
||||
} else g.spice['red_chasm:7'] = 1;
|
||||
const key = multiple ? 'pasty_mesa:6' : 'red_chasm:7';
|
||||
const pending = applyAction(g, 'p', action);
|
||||
assert.equal(pending.decision?.kind, 'intrusion');
|
||||
assert.equal(pending.players[0].spice, 6);
|
||||
assert.equal(pending.spice[key], 0);
|
||||
if (multiple) assert.equal(pending.spice['pasty_mesa:5'], 3);
|
||||
const restored = normalizeAutomaticGame(JSON.parse(JSON.stringify(pending)));
|
||||
assert.deepEqual(restored, pending);
|
||||
const done = applyAction(restored, 'h', { type: 'decision', accept: false });
|
||||
assert.equal(done.players[0].spice, 6);
|
||||
assert.equal(done.spice[key], 0);
|
||||
if (multiple) assert.equal(done.spice['pasty_mesa:5'], 3);
|
||||
assert.equal(done.log.filter(l => l.automatic?.name === 'Sandmaster collection').length, 1);
|
||||
assert.equal(done.players[0].moved, 1);
|
||||
conserved(done);
|
||||
}
|
||||
});
|
||||
void test('all profiles attach collection to legal rides without changing the existing destination policy', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
|
||||
Reference in new issue
Block a user