Connect Diplomat used-defense contexts
This commit is contained in:
1 parent
63298be1e1
commit
628f59b73a
14 files changed
+1048
-66
No files matched your search
@@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { weaponKills } from '../game/battle-cards';
|
||||
import {
|
||||
applyDiplomatContextStep, assertDiplomatContextCustody, copyDiplomatContextJourney,
|
||||
createDiplomatContextJourney, diplomatContextPlayer, finishDiplomatContextJourney,
|
||||
revealDiplomatContextJourney, settleDiplomatContextJourney,
|
||||
} from './fixture-diplomat-context-journey';
|
||||
|
||||
void test('original native World vote closes, invader uses actual late Portable, and Diplomat copies only its poison role before typed custody settles', () => {
|
||||
const fixture = createDiplomatContextJourney();
|
||||
const { game, owner, opponent, trainer, enemyLeader, source, worthless, territory } = fixture;
|
||||
assert.ok(source);
|
||||
const shipment = fixture.shipments[0];
|
||||
assert.equal(diplomatContextPlayer(shipment.before, opponent).spice -
|
||||
diplomatContextPlayer(shipment.after, opponent).spice, 2);
|
||||
assert.equal(diplomatContextPlayer(shipment.before, opponent).reserves -
|
||||
diplomatContextPlayer(shipment.after, opponent).reserves, 3);
|
||||
const worlds = viewGame(game, owner).homeworlds!.worlds!;
|
||||
const nativeWorld = worlds.find(world => world.id === territory)!;
|
||||
const salusa = worlds.find(world => world.id === 'homeworld:emperor:salusa')!;
|
||||
assert.equal(nativeWorld.card, 'kaitain');
|
||||
assert.deepEqual(nativeWorld.forces[owner], { normal: 15, elite: 0 });
|
||||
assert.deepEqual(nativeWorld.forces[opponent], { normal: 3, elite: 0 });
|
||||
assert.deepEqual(salusa.forces[owner], { normal: 0, elite: 5 });
|
||||
|
||||
const revealed = revealDiplomatContextJourney(fixture);
|
||||
assert.equal(revealed.battle!.diplomatDefense, undefined);
|
||||
assert.deepEqual(viewGame(revealed, owner).battle!.traitorVoters, [owner]);
|
||||
assert.equal(revealed.battle!.plans[opponent].defense, null);
|
||||
const nativeVoted = applyAction(revealed, owner, { type: 'traitorCall', call: false });
|
||||
assert.equal(nativeVoted.decision?.kind, 'homeworldDefense');
|
||||
assert.equal(nativeVoted.decision?.player, opponent);
|
||||
assert.equal(nativeVoted.battle!.traitorCalls[owner], false);
|
||||
assert.equal(nativeVoted.battle!.traitorCalls[opponent], undefined);
|
||||
assert.ok(nativeVoted.decision?.kind === 'homeworldDefense');
|
||||
const used = applyDiplomatContextStep(nativeVoted, { actor: opponent,
|
||||
action: { type: 'decision', event: nativeVoted.decision.event, use: true } });
|
||||
assert.equal(used.decision?.kind, 'diplomatDefense');
|
||||
assert.equal(used.battle!.lateDefense![opponent], source);
|
||||
assert.deepEqual(used.battle!.homeworldDefensePassed, [opponent]);
|
||||
assert.equal(used.battle!.traitorCalls[owner], false);
|
||||
assert.deepEqual(used.battle!.diplomatDefense!.cards, [worthless]);
|
||||
assert.equal(used.battle!.diplomatDefense!.source, source);
|
||||
assert.equal(used.battle!.diplomatDefense!.kind, 'snooper');
|
||||
assert.deepEqual(used.battle!.plans, revealed.battle!.plans);
|
||||
const copied = copyDiplomatContextJourney(used, worthless);
|
||||
assert.throws(() => applyAction(copied, owner, { type: 'traitorCall', call: false }),
|
||||
'The submitted native-only vote cannot be reopened after the copied defense.');
|
||||
const settled = settleDiplomatContextJourney(copied);
|
||||
assert.equal(settled.lastBattleContext!.winner, owner);
|
||||
assert.equal(diplomatContextPlayer(settled, owner).leaders.find(l => l.id === trainer)!.dead, false);
|
||||
assert.equal(diplomatContextPlayer(settled, opponent).leaders.find(l => l.id === enemyLeader)!.dead, false);
|
||||
const finalWorlds = viewGame(settled, owner).homeworlds!.worlds!;
|
||||
assert.deepEqual(finalWorlds.find(world => world.id === territory)!.forces[owner], nativeWorld.forces[owner]);
|
||||
assert.deepEqual(finalWorlds.find(world => world.id === 'homeworld:emperor:salusa')!.forces[owner],
|
||||
{ normal: 0, elite: 5 }, 'Copying a defense never spends or relabels Sardaukar.');
|
||||
assert.deepEqual(finalWorlds.find(world => world.id === territory)!.forces[opponent] ?? { normal: 0, elite: 0 },
|
||||
{ normal: 0, elite: 0 });
|
||||
assert.equal(diplomatContextPlayer(settled, opponent).tanks, 3);
|
||||
assert.equal(diplomatContextPlayer(settled, owner).elites!.tanks, 0);
|
||||
assert.equal(settled.discard.filter(card => card.id === worthless).length, 1);
|
||||
assert.equal(settled.discard.filter(card => card.id === source).length, 1);
|
||||
assert.equal(diplomatContextPlayer(settled, owner).hand.some(card => card.id === worthless), false);
|
||||
assertDiplomatContextCustody(settled);
|
||||
assert.equal(finishDiplomatContextJourney(settled).phase, 7);
|
||||
});
|
||||
|
||||
void test('an actually earned Carthag layers its own Snooper onto a copied printed Shield while preserving physical custody', () => {
|
||||
const fixture = createDiplomatContextJourney({ kind: 'held-carthag' });
|
||||
const { owner, opponent, trainer, enemyLeader, worthless, source, firstMentat } = fixture;
|
||||
assert.ok(firstMentat && source);
|
||||
assert.equal(firstMentat.before.strongholdCards!.owners.carthag, null);
|
||||
assert.equal(firstMentat.before.players.find(p => p.id === owner)!.forces['carthag:11'], 1);
|
||||
assert.equal(firstMentat.after.strongholdCards!.owners.carthag, owner);
|
||||
assert.equal(fixture.game.strongholdCards!.owners.carthag, owner);
|
||||
assert.equal(fixture.game.players.find(p => p.id === opponent)!.forces['carthag:11'], 1);
|
||||
for (const shipment of fixture.shipments) {
|
||||
assert.equal(diplomatContextPlayer(shipment.before, shipment.step.actor).reserves -
|
||||
diplomatContextPlayer(shipment.after, shipment.step.actor).reserves, 1);
|
||||
assert.equal(diplomatContextPlayer(shipment.before, shipment.step.actor).spice -
|
||||
diplomatContextPlayer(shipment.after, shipment.step.actor).spice, 1);
|
||||
}
|
||||
assert.equal(weaponKills(
|
||||
diplomatContextPlayer(fixture.game, opponent).hand.find(card => card.id === fixture.enemyWeapon),
|
||||
diplomatContextPlayer(fixture.game, opponent).hand.find(card => card.id === source),
|
||||
), true, 'The exact copied printed Shield by itself does not protect against the exact poison weapon.');
|
||||
const revealed = revealDiplomatContextJourney(fixture);
|
||||
assert.equal(revealed.battle!.diplomatDefense!.source, source);
|
||||
assert.equal(revealed.battle!.diplomatDefense!.kind, 'shield');
|
||||
const settled = settleDiplomatContextJourney(copyDiplomatContextJourney(revealed, worthless));
|
||||
assert.equal(settled.lastBattleContext!.winner, owner);
|
||||
assert.equal(diplomatContextPlayer(settled, owner).leaders.find(l => l.id === trainer)!.dead, false,
|
||||
'Shield alone cannot stop poison: the holder’s original Carthag independently supplies Snooper.');
|
||||
assert.equal(diplomatContextPlayer(settled, opponent).leaders.find(l => l.id === enemyLeader)!.dead, false);
|
||||
assert.equal(diplomatContextPlayer(settled, owner).forces['carthag:11'], 1);
|
||||
assert.equal(diplomatContextPlayer(settled, owner).tanks, 0);
|
||||
assert.equal(diplomatContextPlayer(settled, owner).elites!.tanks, 0);
|
||||
assert.equal(diplomatContextPlayer(settled, opponent).tanks, 1);
|
||||
assert.equal(settled.strongholdCards!.owners.carthag, owner);
|
||||
assert.equal(settled.discard.filter(card => card.id === worthless).length, 1);
|
||||
assert.equal(settled.discard.filter(card => card.id === source).length, 1);
|
||||
assert.equal(diplomatContextPlayer(settled, owner).hand.some(card => card.id === source), false);
|
||||
assertDiplomatContextCustody(settled);
|
||||
assert.equal(finishDiplomatContextJourney(settled).phase, 7);
|
||||
});
|
||||
|
||||
void test('an earned Carthag with only a physical Worthless and no opposing actual defense supplies neither a copy source nor poison protection', () => {
|
||||
const fixture = createDiplomatContextJourney({ kind: 'empty-carthag' });
|
||||
const { owner, opponent, trainer, worthless } = fixture;
|
||||
assert.equal(fixture.firstMentat!.after.strongholdCards!.owners.carthag, owner);
|
||||
const revealed = revealDiplomatContextJourney(fixture);
|
||||
assert.equal(revealed.battle!.plans[owner].weapon, worthless);
|
||||
assert.equal(revealed.battle!.plans[owner].defense, null);
|
||||
assert.equal(revealed.battle!.plans[opponent].defense, null);
|
||||
assert.equal(revealed.battle!.diplomatDefense, undefined);
|
||||
assert.notEqual(revealed.decision?.kind, 'diplomatDefense');
|
||||
const settled = settleDiplomatContextJourney(revealed);
|
||||
assert.equal(diplomatContextPlayer(settled, owner).leaders.find(l => l.id === trainer)!.dead, true);
|
||||
assert.equal(settled.lastBattleContext!.winner, opponent);
|
||||
assert.equal(settled.strongholdCards!.owners.carthag, owner,
|
||||
'Battle occupancy does not replace the original END Mentat card owner.');
|
||||
assert.equal(diplomatContextPlayer(settled, owner).forces['carthag:11'] ?? 0, 0);
|
||||
assert.equal(diplomatContextPlayer(settled, owner).tanks, 1);
|
||||
assert.equal(diplomatContextPlayer(settled, owner).elites!.tanks, 0);
|
||||
assert.equal(settled.discard.filter(card => card.id === worthless).length, 1);
|
||||
assertDiplomatContextCustody(settled);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { advancedNativePlayer, assertAdvancedNativeCustody } from './fixture-advanced-native-skills';
|
||||
import {
|
||||
createDiplomatLateJourney, finishDiplomatLateJourney, settleDiplomatLateJourney,
|
||||
type DiplomatLateJourney, type DiplomatLateJourneyStep,
|
||||
} from './fixture-diplomat-late-journey';
|
||||
|
||||
function assertOutcome(game: Game, fixture: DiplomatLateJourney, branch: 'copy' | 'decline') {
|
||||
const expected = fixture.expected[branch];
|
||||
const own = advancedNativePlayer(game, 'emperor'), opposing = advancedNativePlayer(game, 'guild');
|
||||
assert.equal(own.leaders.find(l => l.id === fixture.trainer)!.dead, expected.trainerDead);
|
||||
assert.equal(opposing.leaders.find(l => l.id === fixture.opposingLeader)!.dead, false,
|
||||
'The actual Portable protects its original holder in either branch.');
|
||||
assert.equal(own.forces[fixture.key] ?? 0, expected.ownForces);
|
||||
assert.equal(opposing.forces[fixture.key] ?? 0, expected.opposingForces);
|
||||
assert.equal(own.tanks, expected.ownTanks);
|
||||
assert.equal(opposing.tanks, expected.opposingTanks);
|
||||
assert.equal(game.lastBattleContext!.winner, expected.winner);
|
||||
assert.equal(game.lastBattleContext!.event, fixture.event);
|
||||
for (const [id, count] of [
|
||||
[fixture.ownPoison, expected.ownPoisonDiscards],
|
||||
[fixture.opposingPoison, expected.opposingPoisonDiscards],
|
||||
[fixture.portable, expected.portableDiscards],
|
||||
[fixture.worthless, expected.worthlessDiscards],
|
||||
] as const) {
|
||||
assert.equal(game.discard.filter(c => c.id === id).length, count, id);
|
||||
const physical = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
||||
...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)];
|
||||
assert.equal(physical.filter(c => c.id === id).length, 1, 'Each original played card keeps exactly one physical custodian.');
|
||||
}
|
||||
assertAdvancedNativeCustody(game);
|
||||
}
|
||||
|
||||
void test('genuine paid Richese programme offers the used late Portable after Emperor already declined traitor, then mandatory Worthless copy survives', () => {
|
||||
const f = createDiplomatLateJourney();
|
||||
for (const shipment of f.shipments) {
|
||||
const before = shipment.before.players.find(p => p.id === shipment.step.actor)!;
|
||||
const after = shipment.after.players.find(p => p.id === shipment.step.actor)!;
|
||||
assert.equal(before.spice - after.spice, shipment.step.actor === f.owner ? 4 : 2);
|
||||
assert.equal(before.reserves - after.reserves, 2);
|
||||
assert.equal(after.forces['south_mesa:5'], 2);
|
||||
}
|
||||
for (const movement of f.movements) {
|
||||
const after = movement.after.players.find(p => p.id === movement.step.actor)!;
|
||||
assert.equal(after.forces[f.key], 2);
|
||||
assert.equal(after.forces['south_mesa:5'] ?? 0, 0);
|
||||
}
|
||||
assert.equal(f.game.spice[f.key], 8);
|
||||
assert.ok(!f.revealed.battle!.diplomatDefense);
|
||||
assert.notEqual(f.revealed.decision?.kind, 'diplomatDefense');
|
||||
assert.equal(f.revealed.battle!.traitorCalls[f.owner], undefined);
|
||||
assert.equal(viewGame(f.revealed, f.owner).battle!.cards.some(c => c.id === f.portable), false);
|
||||
assert.deepEqual(f.beforeLate.battle!.traitorCalls, { [f.owner]: false });
|
||||
assert.equal(viewGame(f.beforeLate, f.opponent).portableSnooper!.blocked, null);
|
||||
const offered = f.offered;
|
||||
assert.ok(offered.decision?.kind === 'diplomatDefense');
|
||||
assert.equal(offered.decision.player, f.owner);
|
||||
assert.equal(offered.decision.source, f.portable);
|
||||
assert.deepEqual(offered.decision.cards, [f.worthless]);
|
||||
assert.equal(offered.battle!.diplomatDefense!.kind, 'snooper');
|
||||
assert.equal(offered.battle!.lateDefense![f.opponent], f.portable);
|
||||
assert.deepEqual(offered.battle!.plans, f.revealed.battle!.plans);
|
||||
assert.equal(offered.battle!.event, f.event);
|
||||
assert.deepEqual(offered.battle!.traitorCalls, { [f.owner]: false });
|
||||
for (const actor of [f.owner, f.opponent, f.observer])
|
||||
assert.ok(viewGame(offered, actor).battle!.cards.some(c => c.id === f.portable));
|
||||
const copied = applyAction(offered, f.copyAction.actor, f.copyAction.action);
|
||||
assert.equal(copied.battle!.diplomatDefense!.stage, 'copied');
|
||||
assert.deepEqual(copied.battle!.plans, f.revealed.battle!.plans);
|
||||
assert.deepEqual(copied.battle!.traitorCalls, { [f.owner]: false });
|
||||
assert.deepEqual(viewGame(copied, f.owner).battle!.traitorVoters.filter(id => copied.battle!.traitorCalls[id] === undefined),
|
||||
f.expected.copy.remainingVoters);
|
||||
assert.ok(advancedNativePlayer(copied, 'emperor').hand.some(c => c.id === f.worthless),
|
||||
'Copy selection reserves the original Worthless until real resolution.');
|
||||
const steps: DiplomatLateJourneyStep[] = [];
|
||||
const { beforeResolution, cleanup } = settleDiplomatLateJourney(copied, f, steps);
|
||||
assert.deepEqual(beforeResolution.battle!.traitorCalls, { [f.owner]: false });
|
||||
assert.equal(cleanup.decision?.kind, 'battleCards');
|
||||
assert.ok(cleanup.decision?.kind === 'battleCards');
|
||||
assert.equal(cleanup.decision.player, f.owner);
|
||||
assert.ok(cleanup.decision.cards.includes(f.ownPoison));
|
||||
assert.ok(!cleanup.decision.cards.includes(f.worthless), 'The selected Worthless is mandatory, not optional winner cleanup.');
|
||||
assertOutcome(cleanup, f, 'copy');
|
||||
const collected = finishDiplomatLateJourney(cleanup, steps);
|
||||
assert.equal(collected.spice[f.key], 8 - f.expected.copy.collection);
|
||||
assert.equal(advancedNativePlayer(collected, 'emperor').spice - advancedNativePlayer(cleanup, 'emperor').spice,
|
||||
f.expected.copy.collection);
|
||||
assert.ok(advancedNativePlayer(collected, 'emperor').hand.some(c => c.id === f.ownPoison));
|
||||
assertOutcome(collected, f, 'copy');
|
||||
});
|
||||
|
||||
void test('declining that same genuine late offer leaves poison lethal and Guild retains its original Portable through winner cleanup and Collection', () => {
|
||||
const f = createDiplomatLateJourney();
|
||||
assert.ok(f.offered.decision?.kind === 'diplomatDefense');
|
||||
const declined = applyAction(f.offered, f.declineAction.actor, f.declineAction.action);
|
||||
assert.equal(declined.battle!.diplomatDefense!.stage, 'declined');
|
||||
assert.equal(declined.battle!.diplomatDefense!.card, null);
|
||||
assert.deepEqual(declined.battle!.traitorCalls, { [f.owner]: false });
|
||||
assert.deepEqual(declined.battle!.plans, f.revealed.battle!.plans);
|
||||
assert.equal(declined.battle!.event, f.event);
|
||||
assert.deepEqual(viewGame(declined, f.owner).battle!.traitorVoters.filter(id => declined.battle!.traitorCalls[id] === undefined),
|
||||
f.expected.decline.remainingVoters);
|
||||
assert.ok(advancedNativePlayer(declined, 'emperor').hand.some(c => c.id === f.worthless));
|
||||
const steps: DiplomatLateJourneyStep[] = [];
|
||||
const { cleanup } = settleDiplomatLateJourney(declined, f, steps);
|
||||
assert.ok(cleanup.decision?.kind === 'battleCards');
|
||||
assert.equal(cleanup.decision.player, f.opponent);
|
||||
assert.ok(cleanup.decision.cards.includes(f.portable));
|
||||
assert.ok(cleanup.decision.cards.includes(f.opposingPoison));
|
||||
assertOutcome(cleanup, f, 'decline');
|
||||
const collected = finishDiplomatLateJourney(cleanup, steps);
|
||||
assert.equal(collected.spice[f.key], 8 - f.expected.decline.collection);
|
||||
assert.ok(advancedNativePlayer(collected, 'guild').hand.some(c => c.id === f.portable));
|
||||
assert.ok(advancedNativePlayer(collected, 'guild').hand.some(c => c.id === f.opposingPoison));
|
||||
assertOutcome(collected, f, 'decline');
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, RuleError, viewGame, type Game } from '../game/engine';
|
||||
import {
|
||||
completedAdvancedNativeSkillsGame, stageAdvancedNativeSkillBattle,
|
||||
openAdvancedNativeSkillBattle, finishAdvancedNativeSkillAftermath,
|
||||
} from './fixture-advanced-native-skills';
|
||||
import { takeHarassCard } from './fixture-harass-withdraw';
|
||||
|
||||
const player = (game: Game, id: string) => game.players.find(p => p.id === id)!;
|
||||
|
||||
/** Conserved rule-unit position after original setup, not a played phase history. */
|
||||
function usedDefense(source: 'late' | 'shield' = 'late', legacy = false) {
|
||||
let game = stageAdvancedNativeSkillBattle(completedAdvancedNativeSkillsGame({
|
||||
family: 'richese', requestedSkill: 'diplomat', skillOwner: 'emperor', opponents: ['guild'],
|
||||
}), 'emperor', 'guild');
|
||||
const worthless = takeHarassCard(game, 'emperor', game.deck.find(c => c.kind === 'worthless')!.id);
|
||||
const ownPoison = source === 'late'
|
||||
? takeHarassCard(game, 'emperor', game.deck.find(c => c.kind === 'poison')!.id) : null;
|
||||
const enemyPoison = takeHarassCard(game, 'guild', game.deck.find(c => c.kind === 'poison')!.id);
|
||||
const shield = source === 'shield'
|
||||
? takeHarassCard(game, 'guild', game.deck.find(c => c.kind === 'shield')!.id) : null;
|
||||
const index = game.richeseCache!.findIndex(c => c.effect === 'portableSnooper');
|
||||
assert.ok(index >= 0);
|
||||
const portable = game.richeseCache!.splice(index, 1)[0];
|
||||
const lateOwner = source === 'late' ? 'guild' : 'emperor';
|
||||
player(game, lateOwner).hand.push(portable);
|
||||
game = openAdvancedNativeSkillBattle(game, 'emperor', 'guild');
|
||||
if (legacy) game.battle!.diplomatDefenseVersion = 2;
|
||||
game = applyAction(game, 'emperor', { type: 'battlePlan', leader: 'emperor-0', dial: 0, support: 0,
|
||||
weapon: ownPoison?.id ?? worthless.id, defense: ownPoison ? worthless.id : null });
|
||||
game = applyAction(game, 'guild', { type: 'battlePlan', leader: 'guild-1', dial: 0, support: 0,
|
||||
weapon: enemyPoison.id, defense: shield?.id });
|
||||
return { game, worthless: worthless.id, portable: portable.id, lateOwner };
|
||||
}
|
||||
|
||||
function copy(game: Game, card: string | null) {
|
||||
assert.ok(game.decision?.kind === 'diplomatDefense');
|
||||
return applyAction(game, 'emperor', { type: 'decision', event: game.decision!.event, card });
|
||||
}
|
||||
|
||||
void test('a newly used late defense offers its actual role without reopening an earlier false traitor vote', () => {
|
||||
const f = usedDefense();
|
||||
let game = applyAction(f.game, 'emperor', { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, 'guild', { type: 'portableSnooper', event: game.battle!.event, card: f.portable });
|
||||
assert.equal(game.decision?.kind, 'diplomatDefense');
|
||||
assert.equal(game.battle!.traitorCalls.emperor, false);
|
||||
assert.equal(game.battle!.diplomatDefense!.source, f.portable);
|
||||
assert.equal(game.battle!.diplomatDefense!.kind, 'snooper');
|
||||
assert.ok(viewGame(game, 'emperor').battle!.cards.some(c => c.id === f.portable));
|
||||
const plans = structuredClone(game.battle!.plans);
|
||||
game = copy(game, f.worthless);
|
||||
assert.deepEqual(game.battle!.plans, plans);
|
||||
const beforeDuplicate = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, 'emperor', { type: 'traitorCall', call: false }), RuleError);
|
||||
assert.deepEqual(game, beforeDuplicate);
|
||||
game = finishAdvancedNativeSkillAftermath(applyAction(game, 'guild', { type: 'traitorCall', call: false }));
|
||||
assert.equal(player(game, 'emperor').leaders.find(l => l.id === 'emperor-0')!.dead, false);
|
||||
assert.equal(game.discard.filter(c => c.id === f.worthless).length, 1);
|
||||
assert.equal(game.discard.filter(c => c.id === f.portable).length, 1);
|
||||
});
|
||||
|
||||
void test('declining an earlier copy preserves a later own Portable Snooper without reopening the skill', () => {
|
||||
const f = usedDefense('shield');
|
||||
let game = copy(f.game, null);
|
||||
game = applyAction(game, 'emperor', { type: 'portableSnooper', event: game.battle!.event, card: f.portable });
|
||||
assert.equal(game.decision, null);
|
||||
assert.equal(game.battle!.diplomatDefense!.stage, 'declined');
|
||||
assert.equal(game.battle!.lateDefense!.emperor, f.portable);
|
||||
assert.ok(viewGame(game, 'guild').battle!.cards.some(c => c.id === f.portable));
|
||||
game = applyAction(game, 'emperor', { type: 'traitorCall', call: false });
|
||||
game = finishAdvancedNativeSkillAftermath(applyAction(game, 'guild', { type: 'traitorCall', call: false }));
|
||||
assert.equal(player(game, 'emperor').leaders.find(l => l.id === 'emperor-0')!.dead, false);
|
||||
assert.ok(player(game, 'emperor').hand.some(c => c.id === f.worthless), 'A declined copy adds no mandatory Worthless disposal');
|
||||
});
|
||||
|
||||
void test('a copied Diplomat defense cannot gain a second actual defense through the late slot', () => {
|
||||
const f = usedDefense('shield');
|
||||
const game = copy(f.game, f.worthless), before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, 'emperor', {
|
||||
type: 'portableSnooper', event: game.battle!.event, card: f.portable,
|
||||
}), RuleError);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('a successful traitor declaration does not open a fresh late-defense copy opportunity', () => {
|
||||
const f = usedDefense();
|
||||
const owner = player(f.game, 'emperor'), identity = 'guild-1';
|
||||
if (!owner.traitors.includes(identity)) {
|
||||
const returned = owner.traitors[0], donor = f.game.players.find(p => p.traitors.includes(identity));
|
||||
if (donor) donor.traitors[donor.traitors.indexOf(identity)] = returned;
|
||||
else {
|
||||
const index = f.game.traitorReserve!.indexOf(identity); assert.ok(index >= 0);
|
||||
f.game.traitorReserve![index] = returned;
|
||||
}
|
||||
owner.traitors[0] = identity;
|
||||
}
|
||||
let game = applyAction(f.game, 'emperor', { type: 'traitorCall', call: true });
|
||||
game = applyAction(game, 'guild', { type: 'portableSnooper', event: game.battle!.event, card: f.portable });
|
||||
assert.equal(game.battle!.traitorCalls.emperor, true);
|
||||
assert.equal(game.battle!.diplomatDefense, undefined);
|
||||
assert.equal(game.decision, null);
|
||||
});
|
||||
|
||||
void test('an already-open printed-only capability keeps its original late-defense continuation', () => {
|
||||
const f = usedDefense('late', true);
|
||||
let game = applyAction(f.game, 'emperor', { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, 'guild', { type: 'portableSnooper', event: game.battle!.event, card: f.portable });
|
||||
assert.equal(game.battle!.traitorCalls.emperor, false);
|
||||
assert.equal(game.battle!.diplomatDefense, undefined);
|
||||
assert.equal(game.decision, null);
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { type Card } from '../game/cards';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills';
|
||||
import { nextNativeSkillsStrongholdStep } from './fixture-native-skills-stronghold';
|
||||
|
||||
export type DiplomatContextStep = { actor: string; action: Action };
|
||||
export type DiplomatContextTransition = { before: Game; step: DiplomatContextStep; after: Game };
|
||||
export type DiplomatContextKind = 'homeworld' | 'held-carthag' | 'empty-carthag';
|
||||
export type DiplomatContextFixture = {
|
||||
initial: Game;
|
||||
offered: Game;
|
||||
afterSetup: Game;
|
||||
training: DiplomatContextTransition[];
|
||||
shipments: DiplomatContextTransition[];
|
||||
firstMentat: DiplomatContextTransition | null;
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
trainer: string;
|
||||
enemyLeader: string;
|
||||
territory: string;
|
||||
kind: DiplomatContextKind;
|
||||
worthless: string;
|
||||
source: string | null;
|
||||
ownWeapon: string;
|
||||
enemyWeapon: string;
|
||||
plans: [DiplomatContextStep, DiplomatContextStep];
|
||||
steps: DiplomatContextStep[];
|
||||
staging: string[];
|
||||
};
|
||||
|
||||
export const diplomatContextQuiet = (game: Game) =>
|
||||
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
const plansReady = (game: Game) => diplomatContextQuiet(game) && !!game.battle &&
|
||||
!game.battle.preparation && game.battle.preLeader?.closed !== false;
|
||||
export const diplomatContextPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
|
||||
/** Scalar shuffle/Storm draws only; UUID byte arrays retain native entropy.
|
||||
* The original all14 skill shuffle is still controlled by its existing helper. */
|
||||
function scalarEntropy<T>(run: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function applyDiplomatContextStep(game: Game, step: DiplomatContextStep): Game {
|
||||
return scalarEntropy(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function nextDiplomatContextStep(game: Game): DiplomatContextStep {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player,
|
||||
action: { type: 'decision', event: decision.event, allow: true } };
|
||||
if (decision?.kind === 'homeworldDefense' || decision?.kind === 'diplomatDefense')
|
||||
throw Error('Stop at the original human late-defense/copy choice.');
|
||||
const next = nextNativeSkillsStrongholdStep(game);
|
||||
assert.ok(next, 'A genuine contextual programme needs a native continuation.'); return next;
|
||||
}
|
||||
export function advanceDiplomatContext(
|
||||
state: Game, until: (game: Game) => boolean, steps: DiplomatContextStep[] = [],
|
||||
): Game {
|
||||
let game = state;
|
||||
for (let n = 0; !until(game) && n < 1800; n++) {
|
||||
assert.equal(game.status, 'playing');
|
||||
const step = nextDiplomatContextStep(game); steps.push(structuredClone(step));
|
||||
game = applyDiplomatContextStep(game, step);
|
||||
}
|
||||
assert.ok(until(game), 'Original contextual programme did not reach its requested boundary.'); return game;
|
||||
}
|
||||
|
||||
/** All physical counters, including foreign Homeworld visitors, retain custody. */
|
||||
export function assertDiplomatContextCustody(game: Game): void {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
if (game.homeworlds) homeworldGameIntegrity(game);
|
||||
const homes = game.homeworlds?.custody
|
||||
? homeworldForceGroups(homeworldContext(game), game.homeworlds.custody) : [];
|
||||
for (const player of game.players) {
|
||||
const visitors = homes.filter(home => home.native !== player.id)
|
||||
.map(home => home.forces[player.id] ?? { normal: 0, elite: 0 });
|
||||
const sum = (groups: Record<string, number>) => Object.values(groups).reduce((a, b) => a + b, 0);
|
||||
assert.equal(player.reserves + player.tanks + sum(player.forces) +
|
||||
visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0), 20);
|
||||
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces) +
|
||||
visitors.reduce((n, pool) => n + pool.elite, 0), 5);
|
||||
}
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
|
||||
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
|
||||
}
|
||||
|
||||
/** Genuine Emperor/Richese/Guild setup, original paid shipment and original
|
||||
* END Mentat. No army, wallet, clock, initiative, owner or receipt is assigned.
|
||||
* Conserved physical card staging below is deliberately NOT an acquisition. */
|
||||
export function createDiplomatContextJourney(options: { initial?: Game; kind?: DiplomatContextKind } = {}): DiplomatContextFixture {
|
||||
const kind = options.kind ?? 'homeworld';
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('DIPLOMATCONTEXT', newPlayer('emperor', 'Emperor', 'emperor'), true, ['choam']);
|
||||
if (!options.initial) for (const faction of ['richese', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
assert.equal(game.status, 'lobby'); assert.equal(game.advanced, true);
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), ['emperor', 'guild', 'richese']);
|
||||
if (kind === 'homeworld' && !game.homeworlds)
|
||||
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
|
||||
if (kind !== 'homeworld' && !game.strongholdCards)
|
||||
game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
|
||||
assert.equal(!!game.homeworlds, kind === 'homeworld');
|
||||
assert.equal(!!game.strongholdCards, kind !== 'homeworld');
|
||||
const initial = structuredClone(game), steps: DiplomatContextStep[] = [];
|
||||
const owner = game.players.find(p => p.faction === 'emperor')!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
||||
game = scalarEntropy(() => initializeAdvancedNativeSkillsSetup({ family: 'richese',
|
||||
requestedSkill: 'diplomat', skillOwner: 'emperor', opponents: ['guild'], initial: game }));
|
||||
const offered = structuredClone(game), training: DiplomatContextTransition[] = [];
|
||||
const act = (step: DiplomatContextStep) => {
|
||||
steps.push(structuredClone(step)); game = applyDiplomatContextStep(game, step); return game;
|
||||
};
|
||||
for (let n = 0; game.status === 'setup' && n < 200; n++) {
|
||||
let step: DiplomatContextStep;
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
||||
const offer = game.leaderSkills!.offers[actor], view = viewGame(game, actor).leaderSkills!;
|
||||
const skill = actor === owner ? 'diplomat' : offer.cards.find(card => !view.unavailableSkills?.[card]);
|
||||
const leaders = diplomatContextPlayer(game, actor).leaders
|
||||
.filter(leader => view.eligibleLeaders.some(candidate => candidate.id === leader.id))
|
||||
.sort((a, b) => b.strength - a.strength);
|
||||
assert.ok(skill && offer.cards.includes(skill) && leaders[0]);
|
||||
step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leaders[0].id } };
|
||||
} else step = nextDiplomatContextStep(game);
|
||||
const before = structuredClone(game); act(step);
|
||||
if (step.action.type === 'leaderSkill') training.push({ before, step, after: structuredClone(game) });
|
||||
}
|
||||
assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
|
||||
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === 'diplomat')!.leader;
|
||||
const staging = [
|
||||
'Original fresh native all14 shuffle offers Diplomat to Emperor; real setup actions select the strongest eligible trainer. Scalar native shuffle and Storm draws are controlled, UUID entropy is untouched.',
|
||||
'Move six original unopened non-worm Spice Cards to the deck front; no spice pile, phase, force, wallet, owner or battle receipt is staged.',
|
||||
];
|
||||
const land = game.spiceDeck.filter(card => 'territory' in card).slice(0, 6);
|
||||
for (const [position, card] of land.entries())
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
game = advanceDiplomatContext(game, until, steps); return game;
|
||||
};
|
||||
const shipments: DiplomatContextTransition[] = [];
|
||||
const ship = (actor: string, action: Action) => {
|
||||
const before = structuredClone(game), step = { actor, action }; act(step);
|
||||
reach(state => diplomatContextQuiet(state) && !state.pendingShipment && !state.pendingHomeworldShipment);
|
||||
shipments.push({ before, step, after: structuredClone(game) });
|
||||
};
|
||||
let firstMentat: DiplomatContextTransition | null = null;
|
||||
if (kind !== 'homeworld') {
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && diplomatContextQuiet(state));
|
||||
ship(owner, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, allyPayment: 0 });
|
||||
reach(state => state.phase === 8 && diplomatContextQuiet(state));
|
||||
assert.equal(game.strongholdCards!.owners.carthag, null);
|
||||
while (game.turn === 1) {
|
||||
const before = structuredClone(game), step = nextDiplomatContextStep(game), after = act(step);
|
||||
if (after.turn === 2) firstMentat = { before, step, after: structuredClone(after) };
|
||||
}
|
||||
assert.ok(firstMentat); assert.equal(game.strongholdCards!.owners.carthag, owner);
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === opponent && diplomatContextQuiet(state));
|
||||
ship(opponent, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, allyPayment: 0 });
|
||||
} else {
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === opponent && diplomatContextQuiet(state));
|
||||
const choice = homeworldShipmentChoice(viewGame(game, opponent), 'homeworld:emperor',
|
||||
{ 'homeworld:guild': { normal: 3, elite: 0 } });
|
||||
assert.ok(choice.action, choice.blocked ?? undefined); assert.ok(choice.cost > 0);
|
||||
ship(opponent, choice.action);
|
||||
}
|
||||
reach(state => state.phase === 6 && diplomatContextQuiet(state));
|
||||
// Deliberately exchange custody, never duplicate cards or claim a purchase.
|
||||
// Keep hand capacity independent of random original starting-card ownership.
|
||||
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
|
||||
const held = (actor: string, predicate: (card: Card) => boolean): string => {
|
||||
for (const pool of [game.deck, game.richeseCache ?? []]) {
|
||||
const index = pool.findIndex(predicate);
|
||||
if (index >= 0) {
|
||||
const card = pool.splice(index, 1)[0]; diplomatContextPlayer(game, actor).hand.push(card);
|
||||
staging.push(`Conserved physical ${card.id}: native stock/cache → ${actor}'s hand; not an acquired or paid card.`);
|
||||
return card.id;
|
||||
}
|
||||
}
|
||||
throw Error('Missing original physical card for labelled conserved custody staging.');
|
||||
};
|
||||
staging.push('At quiet Battle entry return original held Treachery to undealt stock before the labelled stock/cache transfers; all physical IDs and total deck/cache inventory are conserved.');
|
||||
const worthless = held(owner, card => card.kind === 'worthless');
|
||||
const ownWeapon = kind === 'homeworld' ? held(owner, card => card.kind === 'poison') : worthless;
|
||||
const enemyWeapon = held(opponent, card => card.kind === 'poison');
|
||||
const source = kind === 'empty-carthag' ? null : held(opponent, card => kind === 'homeworld'
|
||||
? card.effect === 'portableSnooper' : card.kind === 'shield');
|
||||
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const enemyLeader = diplomatContextPlayer(game, opponent).leaders
|
||||
.filter(leader => !leader.dead && !trained.has(leader.id)).sort((a, b) => a.strength - b.strength)[0].id;
|
||||
const territory = kind === 'homeworld' ? 'homeworld:emperor' : 'carthag';
|
||||
const actor = game.active!;
|
||||
const choice = viewGame(game, actor).battleChoices.find(battle => battle.territory === territory &&
|
||||
[battle.attacker, battle.defender].includes(owner) && [battle.attacker, battle.defender].includes(opponent));
|
||||
assert.ok(choice && choice.chooser === actor);
|
||||
act({ actor, action: { type: 'chooseBattle', territory, target: actor === owner ? opponent : owner } });
|
||||
reach(plansReady); assert.deepEqual(game.battle!.plans, {});
|
||||
const plans: DiplomatContextFixture['plans'] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0,
|
||||
weapon: ownWeapon, ...(kind === 'homeworld' ? { defense: worthless } : {}) } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader, dial: 0, support: 0,
|
||||
weapon: enemyWeapon, ...(kind === 'held-carthag' ? { defense: source } : {}) } },
|
||||
];
|
||||
assertDiplomatContextCustody(game);
|
||||
return { initial, offered, afterSetup, training, shipments, firstMentat, game, owner, opponent,
|
||||
trainer, enemyLeader, territory, kind, worthless, source, ownWeapon, enemyWeapon, plans, steps, staging };
|
||||
}
|
||||
|
||||
export function revealDiplomatContextJourney(fixture: DiplomatContextFixture, steps: DiplomatContextStep[] = []): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const step of fixture.plans) {
|
||||
steps.push(step); game = applyDiplomatContextStep(game, step);
|
||||
if (!game.battle?.revealed) game = advanceDiplomatContext(game, plansReady, steps);
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export function copyDiplomatContextJourney(state: Game, card: string): Game {
|
||||
assert.ok(state.decision?.kind === 'diplomatDefense');
|
||||
return applyDiplomatContextStep(state, { actor: state.decision.player,
|
||||
action: { type: 'decision', event: state.decision.event, card } });
|
||||
}
|
||||
export function settleDiplomatContextJourney(state: Game, steps: DiplomatContextStep[] = []): Game {
|
||||
return advanceDiplomatContext(state, game => !game.battle && diplomatContextQuiet(game), steps);
|
||||
}
|
||||
/** Finish original Battle cleanup and stop at the next original Collection phase. */
|
||||
export function finishDiplomatContextJourney(state: Game, steps: DiplomatContextStep[] = []): Game {
|
||||
return advanceDiplomatContext(state, game => game.phase === 7 && diplomatContextQuiet(game), steps);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, joinGame, newPlayer, viewGame, type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
advancedNativePlayer, initializeAdvancedNativeSkillsSetup,
|
||||
} from './fixture-advanced-native-skills';
|
||||
import { nextSkillsTechBattleStep, type SkillsTechBattleStep } from './fixture-skills-tech-battle';
|
||||
|
||||
export type DiplomatLateJourneyStep = SkillsTechBattleStep;
|
||||
export type DiplomatLateJourneyTransition = {
|
||||
before: Game; step: DiplomatLateJourneyStep; after: Game;
|
||||
};
|
||||
export type DiplomatLateJourney = {
|
||||
initial: Game; beforeSetup: Game; afterSetup: Game;
|
||||
training: DiplomatLateJourneyTransition[];
|
||||
shipments: DiplomatLateJourneyTransition[];
|
||||
movements: DiplomatLateJourneyTransition[];
|
||||
beforeCards: Game; beforeBattle: Game; game: Game;
|
||||
revealed: Game; beforeLate: Game; offered: Game;
|
||||
owner: string; opponent: string; observer: string;
|
||||
trainer: string; opposingLeader: string; territory: string; key: string; event: string;
|
||||
ownPoison: string; opposingPoison: string; worthless: string; portable: string;
|
||||
plans: [DiplomatLateJourneyStep, DiplomatLateJourneyStep];
|
||||
ownerVote: DiplomatLateJourneyStep; portableAction: DiplomatLateJourneyStep;
|
||||
copyAction: DiplomatLateJourneyStep; declineAction: DiplomatLateJourneyStep;
|
||||
remainingVote: DiplomatLateJourneyStep;
|
||||
expected: Record<'copy' | 'decline', {
|
||||
winner: string; trainerDead: boolean; ownForces: number; opposingForces: number;
|
||||
ownTanks: number; opposingTanks: number; ownPoisonDiscards: number;
|
||||
opposingPoisonDiscards: number; portableDiscards: number; worthlessDiscards: number;
|
||||
mandatoryWorthless: boolean; collection: number; remainingVoters: string[];
|
||||
}>;
|
||||
steps: DiplomatLateJourneyStep[]; staging: string[];
|
||||
};
|
||||
|
||||
const quiet = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
const plansReady = (game: Game) => quiet(game) && !!game.battle &&
|
||||
!game.battle.preparation && game.battle.preLeader?.closed !== false;
|
||||
const act = (game: Game, step: DiplomatLateJourneyStep, steps: DiplomatLateJourneyStep[]) => {
|
||||
steps.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
|
||||
};
|
||||
|
||||
/** Native no-bid programme; leave the original Portable in its cache until the
|
||||
* explicitly disclosed transfer. Hidden, unselected other trainers do not lend
|
||||
* their battle skills to this two-actor poison comparison. */
|
||||
export function nextDiplomatLateJourneyStep(game: Game): DiplomatLateJourneyStep {
|
||||
if (game.decision?.kind === 'leaderSkillVisibility')
|
||||
return { actor: game.decision.player, action: { type: 'leaderSkillVisibility',
|
||||
event: game.decision.event, hide: true } };
|
||||
if (game.decision?.kind === 'richeseBlackMarket')
|
||||
return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event, decline: true } };
|
||||
if (game.decision?.kind === 'richeseDeclaration')
|
||||
return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event, position: 'first' } };
|
||||
if (game.decision?.kind === 'richeseCache') {
|
||||
const card = game.richeseCache!.find(c => c.effect !== 'portableSnooper'); assert.ok(card);
|
||||
return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: card.id, method: 'onceAround', direction: 'clockwise' } };
|
||||
}
|
||||
if (game.decision?.kind === 'richeseUnbid')
|
||||
return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
const lot = game.richeseAuction;
|
||||
if (lot && !lot.outcome && quiet(game)) {
|
||||
const actor = lot.method === 'silent'
|
||||
? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active;
|
||||
assert.ok(actor);
|
||||
return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
|
||||
}
|
||||
return nextSkillsTechBattleStep(game);
|
||||
}
|
||||
|
||||
export function advanceDiplomatLateJourney(
|
||||
state: Game, until: (game: Game) => boolean, steps: DiplomatLateJourneyStep[] = [],
|
||||
): Game {
|
||||
let game = state;
|
||||
for (let n = 0; !until(game) && n < 1000; n++) {
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.turn, 1, 'Continue this original first-turn programme only.');
|
||||
game = act(game, nextDiplomatLateJourneyStep(game), steps);
|
||||
}
|
||||
assert.ok(until(game), 'The original Diplomat programme must reach its requested action window.');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Fresh native Richese/Emperor/Guild Advanced all14 setup, actual Diplomat
|
||||
* offer/training, real zero first-Storm dials, paid two-counter South Mesa
|
||||
* shipments and actual movement into a native eight-spice Red Chasm blow.
|
||||
* No live forces, wallets, phase, order, ownership, plan or receipt are assigned.
|
||||
* Only original unopened cards and conserved hand/cache stock are staged below. */
|
||||
export function createDiplomatLateJourney(options: { initial?: Game } = {}): DiplomatLateJourney {
|
||||
let initial: Game;
|
||||
if (options.initial) initial = structuredClone(options.initial);
|
||||
else {
|
||||
initial = createGame('DIPLOMATLATE', newPlayer('emperor', 'Emperor', 'emperor'), true, ['choam']);
|
||||
joinGame(initial, newPlayer('richese', 'Richese', 'richese'));
|
||||
joinGame(initial, newPlayer('guild', 'Guild', 'guild'));
|
||||
}
|
||||
assert.equal(initial.status, 'lobby');
|
||||
const setupOptions = { family: 'richese' as const, requestedSkill: 'diplomat' as const,
|
||||
skillOwner: 'emperor' as const, opponents: ['guild'] as const, initial };
|
||||
let game = initializeAdvancedNativeSkillsSetup(setupOptions);
|
||||
const beforeSetup = structuredClone(game);
|
||||
const owner = advancedNativePlayer(game, 'emperor').id;
|
||||
const opponent = advancedNativePlayer(game, 'guild').id;
|
||||
const observer = advancedNativePlayer(game, 'richese').id;
|
||||
const steps: DiplomatLateJourneyStep[] = [], training: DiplomatLateJourneyTransition[] = [];
|
||||
for (let n = 0; game.status === 'setup' && n < 200; n++) {
|
||||
let step: DiplomatLateJourneyStep;
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
const skill = actor === owner ? 'diplomat' : offer.cards.find(c => !view.unavailableSkills?.[c]);
|
||||
assert.ok(skill && offer.cards.includes(skill) && !view.unavailableSkills?.[skill]);
|
||||
const eligible = game.players.find(p => p.id === actor)!.leaders
|
||||
.filter(l => view.eligibleLeaders.some(candidate => candidate.id === l.id))
|
||||
.sort((a, b) => b.strength - a.strength);
|
||||
assert.ok(eligible[0]);
|
||||
step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: eligible[0].id } };
|
||||
} else step = nextDiplomatLateJourneyStep(game);
|
||||
const before = structuredClone(game);
|
||||
game = act(game, step, steps);
|
||||
if (step.action.type === 'leaderSkill') training.push({ before, step, after: structuredClone(game) });
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
const staging = [
|
||||
'Control only the first original all14 shuffle so Emperor receives Diplomat; train its strongest original eligible disc with the real leaderSkill action. Other native setup and Traitor choices remain actions.',
|
||||
'Reorder original unopened Red Chasm and Broken Land non-worm Spice Cards before the first Advanced Spice Blow. Red Chasm receives its printed eight spice through the native phase, not an assigned pile.',
|
||||
];
|
||||
for (const [position, territory] of ['red_chasm', 'broken_land'].entries()) {
|
||||
const index = game.spiceDeck.findIndex((c, i) => i >= position && 'territory' in c && c.territory === territory);
|
||||
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
const territory = 'red_chasm', key = 'red_chasm:7';
|
||||
const shipments: DiplomatLateJourneyTransition[] = [], movements: DiplomatLateJourneyTransition[] = [];
|
||||
game = advanceDiplomatLateJourney(game, g => g.phase === 5 && quiet(g), steps);
|
||||
assert.equal(game.spice[key], 8);
|
||||
while (game.phase === 5) {
|
||||
if (!quiet(game)) { game = act(game, nextDiplomatLateJourneyStep(game), steps); continue; }
|
||||
const actor = game.active!;
|
||||
if ([owner, opponent].includes(actor)) {
|
||||
const before = structuredClone(game);
|
||||
const shipment: DiplomatLateJourneyStep = { actor, action: {
|
||||
type: 'ship', territory: 'south_mesa', sector: 5, amount: 2, elite: 0, allyPayment: 0,
|
||||
} };
|
||||
game = act(game, shipment, steps);
|
||||
game = advanceDiplomatLateJourney(game, quiet, steps);
|
||||
shipments.push({ before, step: shipment, after: structuredClone(game) });
|
||||
const beforeMove = structuredClone(game);
|
||||
const movement: DiplomatLateJourneyStep = { actor, action: {
|
||||
type: 'move', from: 'south_mesa:5', territory, sector: 7, amount: 2, elite: 0,
|
||||
} };
|
||||
game = act(game, movement, steps);
|
||||
game = advanceDiplomatLateJourney(game, quiet, steps);
|
||||
movements.push({ before: beforeMove, step: movement, after: structuredClone(game) });
|
||||
}
|
||||
game = act(game, { actor, action: { type: 'endMovement' } }, steps);
|
||||
}
|
||||
game = advanceDiplomatLateJourney(game, g => g.phase === 6 && quiet(g), steps);
|
||||
const beforeCards = structuredClone(game);
|
||||
for (const player of game.players) game.deck.push(...player.hand.splice(0));
|
||||
const take = (actor: string, kind: 'poison' | 'worthless') => {
|
||||
const index = game.deck.findIndex(c => c.kind === kind); assert.ok(index >= 0);
|
||||
const card = game.deck.splice(index, 1)[0];
|
||||
game.players.find(p => p.id === actor)!.hand.push(card); return card.id;
|
||||
};
|
||||
const ownPoison = take(owner, 'poison'), opposingPoison = take(opponent, 'poison'), worthless = take(owner, 'worthless');
|
||||
const portableIndex = game.richeseCache!.findIndex(c => c.effect === 'portableSnooper');
|
||||
assert.ok(portableIndex >= 0);
|
||||
const portableCard = game.richeseCache!.splice(portableIndex, 1)[0], portable = portableCard.id;
|
||||
advancedNativePlayer(game, 'guild').hand.push(portableCard);
|
||||
staging.push(`Return every original starting hand to undealt stock, then conservedly transfer original poison ${ownPoison} and Worthless ${worthless} to Emperor, poison ${opposingPoison} to Guild, and original Portable Snooper ${portable} from the still-unbought Richese cache to Guild. No card is created; no paid card acquisition is claimed.`);
|
||||
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === 'diplomat')!.leader;
|
||||
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const opposing = advancedNativePlayer(game, 'guild').leaders
|
||||
.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(opposing);
|
||||
assert.ok(advancedNativePlayer(game, 'emperor').leaders.find(l => l.id === trainer)!.strength > opposing.strength);
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === territory);
|
||||
assert.ok(choice);
|
||||
game = act(game, { actor: choice.chooser, action: { type: 'chooseBattle', territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, steps);
|
||||
game = advanceDiplomatLateJourney(game, plansReady, steps);
|
||||
const controls = structuredClone(game), event = game.battle!.event!;
|
||||
const plans: DiplomatLateJourney['plans'] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0, weapon: ownPoison, defense: worthless } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: opposing.id, dial: 0, support: 0, weapon: opposingPoison } },
|
||||
];
|
||||
for (const plan of plans) {
|
||||
game = act(game, plan, steps); game = advanceDiplomatLateJourney(game, plansReady, steps);
|
||||
}
|
||||
assert.ok(game.battle!.revealed);
|
||||
const revealed = structuredClone(game);
|
||||
const ownerVote: DiplomatLateJourneyStep = { actor: owner, action: { type: 'traitorCall', call: false } };
|
||||
game = act(game, ownerVote, steps);
|
||||
const beforeLate = structuredClone(game);
|
||||
const portableAction: DiplomatLateJourneyStep = { actor: opponent, action: { type: 'portableSnooper', card: portable, event } };
|
||||
game = act(game, portableAction, steps);
|
||||
assert.equal(game.decision?.kind, 'diplomatDefense');
|
||||
assert.ok(game.decision?.kind === 'diplomatDefense');
|
||||
const copyAction: DiplomatLateJourneyStep = { actor: owner,
|
||||
action: { type: 'decision', event: game.decision.event, card: worthless } };
|
||||
const declineAction: DiplomatLateJourneyStep = { actor: owner,
|
||||
action: { type: 'decision', event: game.decision.event, card: null } };
|
||||
const remainingVote: DiplomatLateJourneyStep = { actor: opponent, action: { type: 'traitorCall', call: false } };
|
||||
return { initial, beforeSetup, afterSetup, training, shipments, movements, beforeCards, beforeBattle,
|
||||
game: controls, revealed, beforeLate, offered: game, owner, opponent, observer, trainer,
|
||||
opposingLeader: opposing.id, territory, key, event, ownPoison, opposingPoison, worthless, portable,
|
||||
plans, ownerVote, portableAction, copyAction, declineAction, remainingVote,
|
||||
expected: {
|
||||
copy: { winner: owner, trainerDead: false, ownForces: 2, opposingForces: 0, ownTanks: 0, opposingTanks: 2,
|
||||
ownPoisonDiscards: 0, opposingPoisonDiscards: 1, portableDiscards: 1, worthlessDiscards: 1,
|
||||
mandatoryWorthless: true, collection: 4, remainingVoters: [opponent] },
|
||||
decline: { winner: opponent, trainerDead: true, ownForces: 0, opposingForces: 2, ownTanks: 2, opposingTanks: 0,
|
||||
ownPoisonDiscards: 1, opposingPoisonDiscards: 0, portableDiscards: 0, worthlessDiscards: 1,
|
||||
mandatoryWorthless: false, collection: 4, remainingVoters: [opponent] },
|
||||
}, steps, staging };
|
||||
}
|
||||
|
||||
/** Resolve only the original still-unsubmitted Guild vote. Emperor's earlier
|
||||
* false declaration is never replayed. Stop at real winner-card cleanup. */
|
||||
export function settleDiplomatLateJourney(
|
||||
state: Game, fixture: DiplomatLateJourney, steps: DiplomatLateJourneyStep[] = [],
|
||||
) {
|
||||
assert.equal(state.battle!.traitorCalls[fixture.owner], false);
|
||||
assert.equal(state.battle!.traitorCalls[fixture.opponent], undefined);
|
||||
assert.notEqual(state.decision?.kind, 'diplomatDefense');
|
||||
const beforeResolution = structuredClone(state);
|
||||
const afterVote = act(state, fixture.remainingVote, steps);
|
||||
const cleanup = advanceDiplomatLateJourney(afterVote, g => g.decision?.kind === 'battleCards' && !g.response && !g.phaseOpening, steps);
|
||||
return { beforeResolution, afterVote, cleanup };
|
||||
}
|
||||
|
||||
/** Keep all optional winner cards; advance original phase-opening passes until
|
||||
* ordinary Collection has run, without manufacturing another battle or vote. */
|
||||
export function finishDiplomatLateJourney(state: Game, steps: DiplomatLateJourneyStep[] = []): Game {
|
||||
assert.equal(state.decision?.kind, 'battleCards');
|
||||
const game = act(state, { actor: state.decision!.player, action: { type: 'decision', discard: [] } }, steps);
|
||||
return advanceDiplomatLateJourney(game, g => g.phase === 7 && quiet(g), steps);
|
||||
}
|
||||
Reference in new issue
Block a user