Allow Ecaz Cunning at original quiet phase boundaries
This commit is contained in:
1 parent
a24600ae1d
commit
60f338e2e1
14 files changed
+342
-80
No files matched your search
@@ -352,7 +352,7 @@ void test('original Discovery reveal and free parent entry precede, rather than
|
||||
assert.equal(f.draw.before.turn, 2); assertClosing(f);
|
||||
const later = advance(f.game, s => s.turn === 3 && s.phase === 1 && clean(s));
|
||||
assert.equal(player(later, f.owner).forces['cistern:0'], 2);
|
||||
assert.equal(later.nexusCards!.cards!.hands[f.owner], 'ecaz'); physical(later);
|
||||
physical(later);
|
||||
});
|
||||
|
||||
void test('the existing Basic Harkonnen roster boundary still permits source-clear Cunning without Skills or an invented seat parity guard', () => {
|
||||
@@ -366,15 +366,3 @@ void test('the existing Basic Harkonnen roster boundary still permits source-cle
|
||||
});
|
||||
});
|
||||
|
||||
void test('Advanced Harkonnen is still an eligible no-Skills roster opponent, not authority to bypass the canonical Duke Cunning exclusion', () => {
|
||||
const f = closing({ advanced: true, roster: ['ecaz', 'guild', 'harkonnen'], tech: true, homeworlds: true }); assertClosing(f);
|
||||
const arrival = ship(f.game, f.owner, 'homeworld:guild', 1);
|
||||
const g = advance(arrival.settled, s => s.phase === 6 && clean(s) && !s.battle);
|
||||
const offer = viewGame(g, f.owner).nexusEcazDuke; assert.ok(offer);
|
||||
assert.match(offer.blocked!, /Advanced Harkonnen/);
|
||||
assert.equal(nexusEcazDukeAction(viewGame(g, f.owner)), null);
|
||||
reject(g, f.owner, { type: 'nexusEcazDuke', event: offer.event }, /Advanced Harkonnen/);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], 'ecaz');
|
||||
assert.equal(g.dukeVidal!.controller, null); assert.equal(g.dukeVidal!.leader.dead, false);
|
||||
physical(g);
|
||||
});
|
||||
@@ -328,7 +328,6 @@ void test('quiet-boundary Cunning revives the set-aside Duke from Tanks while in
|
||||
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, usedAt: f.territory } } },
|
||||
{ ...g, dukeVidal: { ...duke, controller: f.owner } },
|
||||
{ ...g, players: g.players.map(p => p.id === f.owner ? { ...p, ally: f.opponent } : p) },
|
||||
{ ...g, phase: 5 },
|
||||
{ ...g, battle: f.game.battle },
|
||||
];
|
||||
for (const input of alternatives) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { DUKE_VIDAL_ID, dukeController } from '../game/duke-vidal';
|
||||
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
|
||||
import { quoteNexusEcazDuke } from '../game/nexus-ecaz-duke';
|
||||
import {
|
||||
advanceNexusDuke, assertNexusDukeCustody, closeNexusDukeDraw,
|
||||
createNexusDukeCaptureFixture, nexusDukeClean, nexusDukePlayer,
|
||||
openNexusDukeBattle, sealNexusDukeBattle, stepNexusDuke,
|
||||
} from './fixture-nexus-duke-capture';
|
||||
|
||||
void test('quiet Revival Cunning liberates the original captive before native shipment and one battle', () => {
|
||||
const f = createNexusDukeCaptureFixture({ variant: 'unused' });
|
||||
const closing = closeNexusDukeDraw(f.game, f.plan, f.actions);
|
||||
const before = advanceNexusDuke(closing.game,
|
||||
g => g.phase === 4 && nexusDukeClean(g), f.plan, f.actions);
|
||||
assert.equal(before.nexusCards!.cards!.hands[f.owner], 'ecaz');
|
||||
assert.equal(dukeController(before.dukeVidal!), f.captor);
|
||||
const action = nexusEcazDukeAction(viewGame(before, f.owner));
|
||||
assert.ok(action, 'Printed Cunning is not restricted to the Battle phase.');
|
||||
const owner = nexusDukePlayer(before, f.owner);
|
||||
const reclaimed = stepNexusDuke(before, f.plan, { actor: f.owner, action }, f.actions);
|
||||
assert.equal(reclaimed.phase, before.phase);
|
||||
assert.equal(reclaimed.active, before.active, 'The original Revival chooser keeps the turn.');
|
||||
assert.equal(dukeController(reclaimed.dukeVidal!), f.owner);
|
||||
assert.equal(reclaimed.dukeVidal!.leader.capturedBy, undefined);
|
||||
assert.equal(nexusDukePlayer(reclaimed, f.owner).spice, owner.spice);
|
||||
assert.equal(nexusDukePlayer(reclaimed, f.owner).leaderRevived, owner.leaderRevived);
|
||||
assert.deepEqual(nexusDukePlayer(reclaimed, f.owner).forces, owner.forces);
|
||||
assert.equal(reclaimed.nexusCards!.cards!.hands[f.owner], null);
|
||||
f.plan.ship = { [f.owner]: { territory: 'carthag', amount: 1 } };
|
||||
const b = openNexusDukeBattle(reclaimed, f.plan, f.actions,
|
||||
{ territory: 'carthag', first: f.owner, second: f.captor });
|
||||
const enemy = nexusDukePlayer(b.game, f.captor).leaders
|
||||
.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(enemy);
|
||||
const sealed = sealNexusDukeBattle(b.game, f.plan, f.actions, [
|
||||
{ actor: f.owner, leader: DUKE_VIDAL_ID, dial: 0, support: 0 },
|
||||
{ actor: f.captor, leader: enemy.id, dial: 0, support: 0 },
|
||||
]);
|
||||
const after = advanceNexusDuke(sealed,
|
||||
g => !g.battle && nexusDukeClean(g), f.plan, f.actions);
|
||||
assert.equal(dukeController(after.dukeVidal!), null);
|
||||
assert.equal(after.dukeVidal!.leader.dead, false);
|
||||
assert.equal(after.dukeVidal!.leader.usedAt, 'carthag');
|
||||
assert.equal(after.dukeVidal!.leader.deaths, before.dukeVidal!.leader.deaths);
|
||||
assertNexusDukeCustody(after);
|
||||
});
|
||||
|
||||
void test('early Cunning respects real Ambassador placement and rejects a token carried across phases', () => {
|
||||
const f = createNexusDukeCaptureFixture({ variant: 'unused' });
|
||||
const closing = closeNexusDukeDraw(f.game, f.plan, f.actions);
|
||||
const revival = advanceNexusDuke(closing.game,
|
||||
g => g.phase === 4 && nexusDukeClean(g), f.plan, f.actions);
|
||||
const stale = nexusEcazDukeAction(viewGame(revival, f.owner));
|
||||
assert.ok(stale);
|
||||
const placement = advanceNexusDuke(revival,
|
||||
g => g.decision?.kind === 'ecazPlacement', f.plan, f.actions);
|
||||
const blocked = quoteNexusEcazDuke(placement, f.owner)!;
|
||||
assert.ok(blocked.blocked);
|
||||
const snapshot = structuredClone(placement);
|
||||
assert.throws(() => applyAction(placement, f.owner,
|
||||
{ type: 'nexusEcazDuke', event: blocked.event }));
|
||||
assert.deepEqual(placement, snapshot);
|
||||
const quiet = advanceNexusDuke(placement,
|
||||
g => g.phase === 5 && nexusDukeClean(g), f.plan, f.actions);
|
||||
const before = structuredClone(quiet);
|
||||
assert.throws(() => applyAction(quiet, f.owner, stale));
|
||||
assert.deepEqual(quiet, before);
|
||||
const current = nexusEcazDukeAction(viewGame(quiet, f.owner));
|
||||
assert.ok(current);
|
||||
const after = stepNexusDuke(quiet, f.plan, { actor: f.owner, action: current }, f.actions);
|
||||
assert.equal(after.phase, 5);
|
||||
assert.equal(after.active, quiet.active);
|
||||
assert.equal(dukeController(after.dukeVidal!), f.owner);
|
||||
assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
|
||||
assertNexusDukeCustody(after);
|
||||
});
|
||||
|
||||
void test('unused early Cunning ends at native turn expiry without restoring the former captor', () => {
|
||||
const f = createNexusDukeCaptureFixture({ variant: 'unused' });
|
||||
const closing = closeNexusDukeDraw(f.game, f.plan, f.actions);
|
||||
const before = advanceNexusDuke(closing.game,
|
||||
g => g.phase === 4 && nexusDukeClean(g), f.plan, f.actions);
|
||||
const action = nexusEcazDukeAction(viewGame(before, f.owner));
|
||||
assert.ok(action);
|
||||
const reclaimed = stepNexusDuke(before, f.plan, { actor: f.owner, action }, f.actions);
|
||||
const after = advanceNexusDuke(reclaimed,
|
||||
g => g.turn > reclaimed.turn, f.plan, f.actions);
|
||||
assert.equal(dukeController(after.dukeVidal!), null);
|
||||
assert.equal(after.dukeVidal!.leader.capturedBy, undefined);
|
||||
assert.equal(after.dukeVidal!.leader.dead, false);
|
||||
assert.equal(after.dukeVidal!.leader.deaths, before.dukeVidal!.leader.deaths);
|
||||
assertNexusDukeCustody(after);
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { resolveEcazDukeRevival } from '../game/ecaz-duke-revival';
|
||||
import {
|
||||
createNexusEcazDukeReceipt, nexusEcazDukeEvent, nexusEcazDukeModeSupported,
|
||||
nexusEcazDukeSignature, quoteNexusEcazDuke, validateNexusEcazDuke,
|
||||
type NexusEcazDukeCapturedReceipt,
|
||||
type NexusEcazDukeReceipt,
|
||||
} from '../game/nexus-ecaz-duke';
|
||||
import { createNexusDukeDeathFixture } from './fixture-nexus-duke-capture';
|
||||
|
||||
@@ -96,12 +96,12 @@ void test('a released witness captive stays unavailable while used but can be li
|
||||
assert.equal(dukeController(expireDuke(liberated, 4)), null);
|
||||
});
|
||||
|
||||
void test('captured v2 receipts reject impossible custody and return transitions even after a fresh signature', () => {
|
||||
void test('captured receipts reject impossible custody and return transitions even after a fresh signature', () => {
|
||||
const g = position();
|
||||
g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal!, 'g', 3, 'ally'), 'h', 'g', 3, 'carthag');
|
||||
const receipt = createNexusEcazDukeReceipt(g, 'e');
|
||||
assert.ok('version' in receipt);
|
||||
const mutations: ((saved: NexusEcazDukeCapturedReceipt) => void)[] = [
|
||||
type Captured = Extract<NexusEcazDukeReceipt, { custody: 'captured' }>;
|
||||
const receipt = createNexusEcazDukeReceipt(g, 'e') as Captured;
|
||||
const mutations: ((saved: Captured) => void)[] = [
|
||||
saved => { saved.before.leader.capturedBy = 'g'; },
|
||||
saved => { saved.before.leader.concealed!.controller = 'h'; },
|
||||
saved => { saved.before.leader.concealed!.deaths++; },
|
||||
@@ -112,7 +112,6 @@ void test('captured v2 receipts reject impossible custody and return transitions
|
||||
saved => { Object.assign(saved.after.leader, { capturedBy: 'h' }); },
|
||||
saved => { Object.assign(saved.before.leader, { gholaBy: 'g' }); },
|
||||
saved => { Object.assign(saved.before.leader.concealed!, { extra: 1 }); },
|
||||
saved => { Object.assign(saved, { version: 3 }); },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const saved = structuredClone(receipt);
|
||||
@@ -129,7 +128,7 @@ void test('captured v2 receipts reject impossible custody and return transitions
|
||||
});
|
||||
|
||||
|
||||
void test('a privately executed return remains a Tanks resurrection with the unchanged old receipt schema', () => {
|
||||
void test('a privately executed return remains a Tanks resurrection without leaking actual prior custody', () => {
|
||||
const g = position();
|
||||
const captured = captureDuke(acquireDuke(g.dukeVidal!, 'g', 3, 'ally'), 'h', 'g', 3, 'arrakeen');
|
||||
g.dukeVidal = captured;
|
||||
@@ -171,11 +170,6 @@ void test('Advanced Harkonnen admission is only the existing standalone no-Skill
|
||||
excluded.dukeVidal!.leader.dead = false;
|
||||
assert.throws(() => quoteEcazDukeAcquisition(excluded, 'e'));
|
||||
}
|
||||
for (const phase of [4, 5, 7]) {
|
||||
const outside = position();
|
||||
outside.phase = phase;
|
||||
assert.notEqual(quoteNexusEcazDuke(outside, 'e')!.blocked, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the actual Ecaz Ghola Treachery return sets the shared disc aside before a separate Cunning acquisition', () => {
|
||||
@@ -228,6 +222,22 @@ void test('the actual Ecaz Ghola Treachery return sets the shared disc aside bef
|
||||
assert.equal(g.nexusCards!.cards!.hands[owner], null);
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
|
||||
const receipt = g.nexusEcazDukeHistory!.at(-1)!;
|
||||
assert.equal('version' in receipt, false, 'ordinary Ecaz Ghola return is not a foreign-Ghola or captive producer');
|
||||
validateNexusEcazDuke(g, receipt, g.nexusEcazDukeHistory!.length - 1);
|
||||
});
|
||||
|
||||
void test('legacy captured Battle records cannot be re-signed as earlier-phase liberation', () => {
|
||||
const g = position();
|
||||
g.dukeVidal = captureDuke(acquireDuke(g.dukeVidal!, 'g', 3, 'ally'), 'h', 'g', 3, 'carthag');
|
||||
const fields = structuredClone(createNexusEcazDukeReceipt(g, 'e')) as unknown as Record<string, unknown>;
|
||||
fields.version = 2;
|
||||
delete fields.custody;
|
||||
const legacy = fields as NexusEcazDukeReceipt;
|
||||
legacy.signature = nexusEcazDukeSignature(legacy);
|
||||
validateNexusEcazDuke(g, legacy, 0);
|
||||
const invalid = structuredClone(legacy);
|
||||
invalid.phase = 5;
|
||||
invalid.event = nexusEcazDukeEvent(invalid.turn, invalid.phase, invalid.owner,
|
||||
invalid.sequence, invalid.before.controller, invalid.before.acquiredTurn);
|
||||
invalid.signature = nexusEcazDukeSignature(invalid);
|
||||
assert.throws(() => validateNexusEcazDuke(g, invalid, 0));
|
||||
});
|
||||
@@ -234,26 +234,6 @@ void test('ordinary Ecaz Ambassador custody survives end turn; Nexus custody doe
|
||||
}
|
||||
});
|
||||
|
||||
void test('native owner sees explicit blocks; no card identity leaks to other seats', () => {
|
||||
const g = game();
|
||||
assert.equal(quoteNexusEcazDuke(g, 'moritani-seat'), null);
|
||||
assert.match(quoteNexusEcazDuke({ ...g, phase: 5 }, 'ecaz-seat')!.blocked!, /Battle phase/);
|
||||
assert.match(quoteNexusEcazDuke({ ...g, battle: {} as Game['battle'] }, 'ecaz-seat')!.blocked!,
|
||||
/current interaction/);
|
||||
assert.match(quoteNexusEcazDuke(g, 'ecaz-seat', true)!.blocked!, /current interaction/);
|
||||
const harkonnen = {
|
||||
...g, advanced: true,
|
||||
players: [...g.players.slice(0, 2),
|
||||
{ ...g.players[2], faction: 'harkonnen' as const }],
|
||||
};
|
||||
assert.match(quoteNexusEcazDuke(harkonnen, 'ecaz-seat')!.blocked!,
|
||||
/Advanced Harkonnen/);
|
||||
assert.equal(quoteNexusEcazDuke({ ...harkonnen, advanced: false }, 'ecaz-seat')!.blocked,
|
||||
null);
|
||||
const allied = structuredClone(g);
|
||||
allied.players[0].ally = allied.players[1].id;
|
||||
assert.match(quoteNexusEcazDuke(allied, 'ecaz-seat')!.blocked!, /unallied/);
|
||||
});
|
||||
|
||||
void test('damaged and re-signed impossible history cannot forge the original Duke or seated mode', () => {
|
||||
const g = game();
|
||||
@@ -286,7 +266,7 @@ void test('damaged and re-signed impossible history cannot forge the original Du
|
||||
invalid(record => { record.roster = ['ecaz', 'ecaz']; });
|
||||
invalid(record => { record.advanced = true; });
|
||||
invalid(record => { record.turn = 4; });
|
||||
invalid(record => { record.phase = 5 as never; });
|
||||
invalid(record => { record.phase = 7; });
|
||||
invalid(record => { record.sequence = 1; });
|
||||
invalid(record => { record.event = nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, null, null); });
|
||||
invalid(record => { (record as NexusEcazDukeReceipt & { extra: boolean }).extra = true; });
|
||||
@@ -295,3 +275,19 @@ void test('damaged and re-signed impossible history cannot forge the original Du
|
||||
assert.throws(() => validateNexusEcazDuke({ ...g, phase: 5 }, original, 0));
|
||||
assert.throws(() => validateNexusEcazDuke({ ...g, nexusCards: null }, original, 0));
|
||||
});
|
||||
|
||||
void test('legacy Battle-only records cannot be re-signed as earlier-phase Cunning', () => {
|
||||
const g = game();
|
||||
const fields = structuredClone(createNexusEcazDukeReceipt(g, 'ecaz-seat')) as unknown as Record<string, unknown>;
|
||||
delete fields.version;
|
||||
delete fields.custody;
|
||||
const legacy = fields as NexusEcazDukeReceipt;
|
||||
legacy.signature = nexusEcazDukeSignature(legacy);
|
||||
validateNexusEcazDuke(g, legacy, 0);
|
||||
const invalid = structuredClone(legacy);
|
||||
invalid.phase = 5;
|
||||
invalid.event = nexusEcazDukeEvent(invalid.turn, invalid.phase, invalid.owner,
|
||||
invalid.sequence, invalid.before.controller, invalid.before.acquiredTurn);
|
||||
invalid.signature = nexusEcazDukeSignature(invalid);
|
||||
assert.throws(() => validateNexusEcazDuke(g, invalid, 0));
|
||||
});
|
||||
Reference in new issue
Block a user