Support public leader preparation with Harkonnen
Verify and publish checkpoint container / container (push) Successful in 10m56s
Verify and publish checkpoint container / container (push) Successful in 10m56s
This commit is contained in:
1 parent
f9ca7ed9a9
commit
31c6e79c80
6 files changed
+288
-11
No files matched your search
+15
-2
@@ -17,6 +17,19 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic leaderless preparation with Harkonnen — 29 October. Prototyped /
|
||||
Partial.** Fresh classic Basic games with Harkonnen now use public native
|
||||
leader snapshots to open dead-leader preparation and qualify early notices.
|
||||
Controlled captives remain legal leaders. Harkonnen's own early notice stays
|
||||
at plan submission while it retains a concealed captive, avoiding an identity
|
||||
inference. Genuine public setup and shipments with staged leader history and
|
||||
conserved captive/card custody cover hidden retained and executed identities,
|
||||
Ghola and four minimal bot profiles. Eight related files pass 39 cases,
|
||||
including six new cases; type checking, lint and production build pass. The
|
||||
timing is provisional; broader faction/module combinations, the deferred
|
||||
Harkonnen notice and deployed acceptance remain open.
|
||||
[Scope](RULE_DECISIONS.md#basic-leaderless-battle-announcement--29-october-2026).
|
||||
|
||||
**Basic group Fremen revival scheduling — 29 October. Prototyped / Partial.**
|
||||
During concurrent Basic Revival, automated group allies wait individually for
|
||||
a human Fremen's eligible, useful free-revival grant. Grant, targeted Karama
|
||||
@@ -41,8 +54,8 @@ leader and conserved card state cover mixed and dead leaders, both combatants,
|
||||
Prescience, Ghola before and after readiness, sealed plans and all four legal
|
||||
bot profiles. Eight related files pass 59 cases, including 15 new runtime and
|
||||
control cases; type checking, lint and the production build pass. The ordering
|
||||
is provisional; Harkonnen's concealed captures, other factions and modules,
|
||||
and deployed acceptance remain open.
|
||||
is provisional; the Harkonnen follow-up above extends part of this scope.
|
||||
Other factions and modules and deployed acceptance remain open.
|
||||
[Scope](RULE_DECISIONS.md#basic-leaderless-battle-announcement--29-october-2026).
|
||||
|
||||
**Basic market Ghola with printed Kull — 29 October. Prototyped / Partial.**
|
||||
|
||||
@@ -65,6 +65,16 @@ Great Maker rides share this destination check; Polar Sink remains clear.
|
||||
|
||||
## Basic leaderless battle announcement — 29 October 2026
|
||||
|
||||
Fresh classic Basic games with Harkonnen also open the neutral dead-leader
|
||||
preparation using each native leader's public pre-capture snapshot. An
|
||||
announcement is made only when the public native discs are unavailable, so it
|
||||
does not identify a secretly captured leader. Harkonnen can use a controlled
|
||||
captive as a leader. While Harkonnen holds any concealed captive, its own early
|
||||
announcement remains deferred to sealing because different private captive
|
||||
identities may have different eligibility. These are provisional online
|
||||
timing and privacy boundaries; optional modules remain separate. Older open
|
||||
battles retain their original version.
|
||||
|
||||
In fresh classic Basic games without Harkonnen, a publicly unavailable living
|
||||
leader pool containing at least one dead leader now opens a neutral pre-leader
|
||||
preparation for that combatant, independent of their private hand. An unaffected
|
||||
@@ -90,6 +100,7 @@ a provisional interpretation rather than a specific publisher timing ruling.
|
||||
[Early runtime](../tests/basic-early-leaderless.test.ts),
|
||||
[dead-leader runtime](../tests/basic-early-leaderless-dead.test.ts),
|
||||
[dead-leader controls](../tests/basic-early-leaderless-dead-controls.test.tsx),
|
||||
[Harkonnen runtime](../tests/basic-early-leaderless-harkonnen.test.ts),
|
||||
[early controls](../tests/basic-early-leaderless-controls.test.tsx),
|
||||
[Voice cases](../tests/basic-early-leaderless-voice.test.ts),
|
||||
[seal-time runtime](../tests/basic-leaderless-announcement.test.ts) and
|
||||
|
||||
+27
-8
@@ -993,6 +993,8 @@ export type Battle = {
|
||||
basicEarlyLeaderless?: string[];
|
||||
/** Fresh classic battles with a neutral dead-leader preparation opportunity. */
|
||||
basicDeadLeaderPreparation?: true;
|
||||
/** Fresh Harkonnen games qualify early notices from public leader snapshots. */
|
||||
basicPublicLeaderlessVersion?: 1;
|
||||
revealed: boolean;
|
||||
traitorCalls: Record<string, boolean>;
|
||||
traitorDecisionPromises?: TraitorDecisionPromise[];
|
||||
@@ -43026,6 +43028,17 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
charityOrderIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
/** Public native-disc state, independent of concealed capture identity. */
|
||||
function publicNativeBattleLeaders(player: Player, territory: string) {
|
||||
const leaders = player.leaders.map(leader => ({
|
||||
dead: leader.concealed ? leader.concealed.dead : leader.dead,
|
||||
usedAt: leader.concealed ? leader.concealed.usedAt : leader.usedAt,
|
||||
captured: !leader.concealed && !!leader.capturedBy,
|
||||
}));
|
||||
return { hasDead: leaders.some(leader => leader.dead),
|
||||
unavailable: !leaders.some(leader => !leader.dead && !leader.captured &&
|
||||
(!leader.usedAt || leader.usedAt === territory)) };
|
||||
}
|
||||
/** Publish a clean Basic inability once preparation and battle powers finish. */
|
||||
function announceEarlyBasicLeaderless(g: Game) {
|
||||
const b = g.battle;
|
||||
@@ -43036,7 +43049,9 @@ function announceEarlyBasicLeaderless(g: Game) {
|
||||
for (const id of [b.attacker, b.defender]) {
|
||||
if (b.plans[id]) continue;
|
||||
const p = getPlayer(g, id), leaders = controlledLeaders(g, p);
|
||||
const availableLeader = leaders.some(l => !l.dead && !l.capturedBy && (!l.usedAt || l.usedAt === b.territory));
|
||||
// controlledLeaders already includes captives that Harkonnen may use.
|
||||
// Keep this identical to the availability check in validatePlan.
|
||||
const availableLeader = leaders.some(l => !l.dead && (!l.usedAt || l.usedAt === b.territory));
|
||||
const availableHero = p.hand.some(c => c.kind === 'hero' && !(b.voice?.target === id && !b.voice.must &&
|
||||
playedVoiceMatch(c, 'leader', b.voice.kind)));
|
||||
if (b.basicEarlyLeaderless?.includes(id)) {
|
||||
@@ -43046,7 +43061,11 @@ function announceEarlyBasicLeaderless(g: Game) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!leaders.length || availableLeader || availableHero ||
|
||||
if (b.basicPublicLeaderlessVersion === 1 &&
|
||||
(!publicNativeBattleLeaders(p, b.territory).unavailable ||
|
||||
p.faction === 'harkonnen' && g.players.some(owner => owner.leaders.some(leader =>
|
||||
leader.concealed?.captor === p.id && leader.capturedBy === p.id)))) continue;
|
||||
if ((!leaders.length && !b.basicDeadLeaderPreparation) || availableLeader || availableHero ||
|
||||
!b.basicDeadLeaderPreparation && leaders.some(l => l.dead || l.capturedBy)) continue;
|
||||
(b.basicEarlyLeaderless ??= []).push(id);
|
||||
log(g, `${p.name} has no available leader or Cheap Hero for this battle.`);
|
||||
@@ -47371,19 +47390,19 @@ function applyActionInner(
|
||||
!g.strongholdCards && !g.leaderSkills && !g.homeworlds && !g.nexusCards && !g.discoveryEnabled &&
|
||||
!g.sandtrout && !g.coalitionState && g.players.every(seat =>
|
||||
['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction));
|
||||
// The candidate depends only on public native leaders, never either hand.
|
||||
// Harkonnen's concealed captures need a separate public-projection rule.
|
||||
const deadLeaderCandidates = earlyBasic && !byFaction(g, 'harkonnen')
|
||||
// Use the same pre-capture snapshot visible to uninvolved players. A
|
||||
// neutral preparation opportunity must not identify a concealed captive.
|
||||
const deadLeaderCandidates = earlyBasic
|
||||
? [choice.attacker, choice.defender].filter(playerId => {
|
||||
const leaders = getPlayer(g, playerId).leaders;
|
||||
return leaders.some(leader => leader.dead) && !leaders.some(leader =>
|
||||
!leader.dead && !leader.capturedBy && (!leader.usedAt || leader.usedAt === choice.territory));
|
||||
const publicState = publicNativeBattleLeaders(getPlayer(g, playerId), choice.territory);
|
||||
return publicState.hasDead && publicState.unavailable;
|
||||
}) : [];
|
||||
g.battle = {
|
||||
...choice,
|
||||
chooserEvent: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0),
|
||||
event: battleEvent,
|
||||
...(earlyBasic ? { basicEarlyLeaderlessVersion: 1 as const } : {}),
|
||||
...(earlyBasic && byFaction(g, 'harkonnen') ? { basicPublicLeaderlessVersion: 1 as const } : {}),
|
||||
...(deadLeaderCandidates.length ? { basicDeadLeaderPreparation: true as const,
|
||||
preLeader: { event: battleEvent,
|
||||
ready: [choice.attacker, choice.defender].filter(playerId => !deadLeaderCandidates.includes(playerId)),
|
||||
|
||||
+1
-1
@@ -2847,7 +2847,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'Build a legal combination and decide what to keep after winning.',
|
||||
steps: [
|
||||
'Choose one weapon, one defense, both, or neither. A worthless card can occupy a weapon or defense slot.',
|
||||
'Choose an available leader or Cheap Hero when required. Without either, battle cards cannot be played. In supported fresh classic Basic battles, an inability is announced before plans. When dead leaders may be revived, a preparation opportunity lets you play Ghola from your hand before continuing; a later revival updates the public notice. Other Basic and Advanced leaderless plans announce on sealing; its dial, spice and other choices remain sealed. Refresh retains the same announcement without submitting another plan.',
|
||||
'Choose an available leader or Cheap Hero when required. Without either, battle cards cannot be played. In supported fresh classic Basic battles, an inability is announced before plans. When dead leaders may be revived, a preparation opportunity lets you play Ghola from your hand before continuing; a later revival updates the public notice. Harkonnen can use a captive leader; its announcement stays at plan submission while it holds a concealed captive. Other Basic and Advanced leaderless plans announce on sealing; its dial, spice and other choices remain sealed. Refresh retains the same announcement without submitting another plan.',
|
||||
'On Arrakis, a surviving leader remains in its battle territory for the rest of the Battle phase. A later Lasgun–Shield explosion there also kills previously surviving leader discs still present, even if a different leader fights that later battle. The Basic application is provisional; a successful Traitor call prevents the explosion.',
|
||||
'A loser normally discards all played cards. If allied with Moritani and the battle has a winner, the defeated ally may retain one played card that a winner could keep. A winner may discard selected played cards; Cheap Heroes are discarded after use.',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createBasicLeaderlessHarkonnenFixture, leaderlessSeat as seat,
|
||||
stageLeaderlessCard } from './fixture-basic-leaderless-harkonnen';
|
||||
|
||||
const act = (game:Game,id:string,action:Action) => applyAction(game,id,action);
|
||||
let fixture: ReturnType<typeof createBasicLeaderlessHarkonnenFixture> | undefined;
|
||||
const source = () => fixture ??= createBasicLeaderlessHarkonnenFixture();
|
||||
const notice = (game:Game) => game.log.filter(entry =>
|
||||
entry.text === `${seat(game,'h').name} has no available leader or Cheap Hero for this battle.`);
|
||||
const empty:Action = {type:'battlePlan',leader:null,dial:0,support:0,weapon:null,defense:null};
|
||||
|
||||
/** Public setup and shipments are real. Leader use/death, concealed captive
|
||||
* custody and conserved Ghola/Hero cards are explicitly staged before the
|
||||
* actual choice; hidden variants retain the same public leader snapshots. */
|
||||
function beforeChoice(options:{card?:'none'|'hero'|'ghola'; captive?:number; captiveAvailable?:boolean} = {}) {
|
||||
const f = source(), game = structuredClone(f.game), harkonnen = seat(game,'h');
|
||||
for (const [index,leader] of harkonnen.leaders.entries()) {
|
||||
if (index === 0) {leader.dead = true; leader.deaths = Math.max(1,leader.deaths); delete leader.usedAt;}
|
||||
else {leader.dead = false; leader.usedAt = 'carthag';}
|
||||
}
|
||||
stageLeaderlessCard(game,'h',options.card ?? 'none');
|
||||
let captive:string | undefined;
|
||||
if (options.captive !== undefined) {
|
||||
const owner = seat(game,'e');
|
||||
// The two candidate discs have the same public history. Their private
|
||||
// post-capture use can differ without changing the recorded public face.
|
||||
for (const leader of owner.leaders.slice(0,2)) {assert.ok(!leader.dead); leader.usedAt = 'carthag';}
|
||||
const leader = owner.leaders[options.captive]; assert.ok(leader);
|
||||
assert.ok(!leader.dead && !leader.capturedBy);
|
||||
leader.concealed = {captor:'h',controller:'e',dead:leader.dead,deaths:leader.deaths,usedAt:leader.usedAt};
|
||||
if (options.captiveAvailable !== false) leader.usedAt = 'imperial_basin';
|
||||
leader.capturedBy = 'h'; captive = leader.id;
|
||||
}
|
||||
return {game, action:f.action, chooser:f.chooser, captive};
|
||||
}
|
||||
function selected(options:Parameters<typeof beforeChoice>[0] = {}) {
|
||||
const f = beforeChoice(options), game = act(f.game,f.chooser,f.action);
|
||||
assert.ok(game.battle?.preLeader && !game.battle.preLeader.closed);
|
||||
assert.ok(!game.battle.preLeader.ready.includes('h'));
|
||||
assert.ok(game.battle.preLeader.ready.includes('e'));
|
||||
return {...f,game};
|
||||
}
|
||||
function ready(game:Game):Game {
|
||||
const event = game.battle?.preLeader?.event; assert.ok(event);
|
||||
return act(game,'h',{type:'battlePreparationReady',event});
|
||||
}
|
||||
function emperorPlan(game:Game):Action {
|
||||
const leader = seat(game,'e').leaders.find(l => !l.dead && !l.capturedBy &&
|
||||
(!l.usedAt || l.usedAt === game.battle!.territory));
|
||||
assert.ok(leader); return {...empty,leader:leader.id};
|
||||
}
|
||||
|
||||
void test('Harkonnen with dead and used native leaders gets an early preparation and announcement', () => {
|
||||
let {game} = selected();
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game,'e',emperorPlan(game)));
|
||||
assert.deepEqual(game,before);
|
||||
game = ready(game);
|
||||
assert.deepEqual(viewGame(game,'g').battle!.earlyLeaderless,['h']);
|
||||
assert.equal(notice(game).length,1);
|
||||
game = act(game,'e',emperorPlan(game));
|
||||
game = act(game,'h',empty);
|
||||
assert.equal(game.battle!.revealed,true);
|
||||
assert.equal(notice(game).length,1);
|
||||
});
|
||||
|
||||
void test('a concealed usable captive prevents a false Harkonnen leaderless announcement', () => {
|
||||
const {game: initial,captive} = selected({captive:0}); let game = initial; assert.ok(captive);
|
||||
assert.equal(viewGame(game,'g').players.find(p => p.id === 'e')!.leaders.find(l => l.id === captive)!.capturedBy,undefined);
|
||||
game = ready(game);
|
||||
assert.equal(notice(game).length,0);
|
||||
assert.ok(!(viewGame(game,'g').battle!.earlyLeaderless ?? []).includes('h'));
|
||||
game = act(game,'h',{...empty,leader:captive});
|
||||
assert.equal(game.battle!.plans.h.leader,captive);
|
||||
assert.equal(game.battle!.revealed,false);
|
||||
});
|
||||
|
||||
void test('concealed capture identity does not affect the public pre-leader opportunity', () => {
|
||||
const forks = [selected({captive:0}),selected({captive:1,captiveAvailable:false})];
|
||||
assert.notEqual(forks[0].captive,forks[1].captive);
|
||||
const publicFields = (game:Game) => {
|
||||
const view = viewGame(game,'g');
|
||||
return {leaders:view.players.map(player => ({id:player.id,leaders:player.leaders})),
|
||||
stage:{ready:view.battle!.preLeader!.ready,closed:view.battle!.preLeader!.closed},
|
||||
early:view.battle!.earlyLeaderless};
|
||||
};
|
||||
assert.deepEqual(publicFields(forks[0].game),publicFields(forks[1].game));
|
||||
const closed = forks.map(f => ready(f.game));
|
||||
assert.deepEqual(publicFields(closed[0]),publicFields(closed[1]));
|
||||
assert.equal(notice(closed[0]).length,0);
|
||||
assert.equal(notice(closed[1]).length,0);
|
||||
const playable = act(closed[0],'h',{...empty,leader:forks[0].captive});
|
||||
assert.equal(playable.battle!.plans.h.leader,forks[0].captive);
|
||||
const leaderless = act(closed[1],'h',empty);
|
||||
assert.equal(leaderless.battle!.plans.h.leader,null);
|
||||
});
|
||||
|
||||
void test('concealed capture or execution of the only public available leader does not cause an early notice', () => {
|
||||
const f = source();
|
||||
const forks = ([{captured:0,executed:false},{captured:1,executed:false},
|
||||
{captured:0,executed:true},{captured:1,executed:true}] as const).map(({captured,executed}) => {
|
||||
const game = structuredClone(f.game), emperor = seat(game,'e');
|
||||
for (const leader of emperor.leaders) {leader.dead = false; leader.usedAt = 'carthag';}
|
||||
delete emperor.leaders[0].usedAt;
|
||||
stageLeaderlessCard(game,'e','none');
|
||||
const leader = emperor.leaders[captured];
|
||||
leader.concealed = {captor:'h',controller:'e',dead:leader.dead,deaths:leader.deaths,
|
||||
...(leader.usedAt ? {usedAt:leader.usedAt} : {})};
|
||||
if (executed) {
|
||||
leader.dead = true; leader.deaths = Math.max(2,leader.deaths + 1);
|
||||
delete leader.usedAt; delete leader.capturedBy;
|
||||
} else leader.capturedBy = 'h';
|
||||
return act(game,f.chooser,f.action);
|
||||
});
|
||||
const publicFields = (game:Game) => {
|
||||
const view = viewGame(game,'g');
|
||||
return {leaders:view.players.map(player => ({id:player.id,leaders:player.leaders})),
|
||||
stage:view.battle!.preLeader ? {ready:view.battle!.preLeader.ready,
|
||||
closed:view.battle!.preLeader.closed} : null,
|
||||
early:view.battle!.earlyLeaderless};
|
||||
};
|
||||
for (const fork of forks.slice(1)) assert.deepEqual(publicFields(forks[0]),publicFields(fork));
|
||||
for (const game of forks) {
|
||||
assert.ok(!(viewGame(game,'g').battle!.earlyLeaderless ?? []).includes('e'));
|
||||
assert.equal(game.log.filter(entry => entry.text ===
|
||||
`${seat(game,'e').name} has no available leader or Cheap Hero for this battle.`).length,0);
|
||||
}
|
||||
// The owner may make the normal public announcement by actually sealing a
|
||||
// leaderless plan, while the other privately held leader remains required.
|
||||
const sealed = act(forks[0],'e',empty);
|
||||
assert.equal(sealed.battle!.plans.e.leader,null);
|
||||
assert.throws(() => act(forks[1],'e',empty));
|
||||
const executed = act(forks[2],'e',empty);
|
||||
assert.equal(executed.battle!.plans.e.leader,null);
|
||||
assert.throws(() => act(forks[3],'e',empty));
|
||||
});
|
||||
|
||||
void test('Harkonnen can use physical Ghola during or after preparation', () => {
|
||||
for (const late of [false,true]) {
|
||||
let {game} = selected({card:'ghola'});
|
||||
const dead = seat(game,'h').leaders.find(l => l.dead), card = seat(game,'h').hand.find(c => c.effect === 'ghola');
|
||||
assert.ok(dead && card);
|
||||
if (late) {game = ready(game); assert.equal(notice(game).length,1);}
|
||||
game = act(game,'h',{type:'card',card:card.id,leader:dead.id});
|
||||
assert.equal(seat(game,'h').leaders.find(l => l.id === dead.id)!.dead,false);
|
||||
assert.equal(game.discard.filter(c => c.id === card.id).length,1);
|
||||
if (!late) game = ready(game);
|
||||
assert.ok(!(viewGame(game,'g').battle!.earlyLeaderless ?? []).includes('h'));
|
||||
game = act(game,'h',{...empty,leader:dead.id});
|
||||
assert.equal(game.battle!.plans.h.leader,dead.id);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal bot profiles can leave Harkonnen preparation legally', () => {
|
||||
const {game} = selected();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game,'h'); view.players.find(p => p.id === 'h')!.bot = difficulty;
|
||||
const action = botActions(view).find(a => a.type === 'battlePreparationReady'); assert.ok(action);
|
||||
const next = act(structuredClone(game),'h',action);
|
||||
assert.equal(next.battle!.preLeader!.closed,true);
|
||||
assert.equal(notice(next).length,1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { actSalusaGhola, advanceSalusaGhola, salusaGholaClean } from './fixture-salusa-ghola';
|
||||
|
||||
export function leaderlessSeat(game: Game, id: string) {
|
||||
const seat = game.players.find(player => player.id === id); assert.ok(seat); return seat;
|
||||
}
|
||||
|
||||
/** Public Basic start with Harkonnen and Emperor's actual paid shipments to
|
||||
* Imperial Basin. Only unopened Spice order is selected. Later tests openly
|
||||
* stage leader history/capture and conserved physical hand custody. */
|
||||
export function createBasicLeaderlessHarkonnenFixture() {
|
||||
let game = createGame('BASICHARKLEADER', newPlayer('h','Harkonnen','harkonnen'), false);
|
||||
for (const [id,faction] of [['e','emperor'],['g','guild'],['a','atreides']] as const)
|
||||
joinGame(game, newPlayer(id, faction, faction));
|
||||
const act = (id:string, action:Action) => {game = actSalusaGhola(game,id,action);};
|
||||
const reach = (until:(g:Game)=>boolean) => {game = advanceSalusaGhola(game,until);};
|
||||
for (const seat of game.players) act(seat.id,{type:'ready'});
|
||||
act('h',{type:'start'});
|
||||
assert.equal(game.status,'setup');
|
||||
reach(g => g.status === 'playing');
|
||||
for (let position = 0; position < 2; position++) {
|
||||
const index = game.spiceDeck.findIndex((card,i) => i >= position && 'territory' in card &&
|
||||
!card.discovery && card.territory !== 'imperial_basin');
|
||||
assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position,0,game.spiceDeck.splice(index,1)[0]);
|
||||
}
|
||||
reach(g => g.phase === 5 && salusaGholaClean(g));
|
||||
for (let n = 0; game.phase === 5 && n < 100; n++) {
|
||||
if (!salusaGholaClean(game)) {reach(salusaGholaClean); continue;}
|
||||
const owner = game.active!;
|
||||
if (owner === 'h' || owner === 'e') {
|
||||
act(owner,{type:'ship',territory:'imperial_basin',sector:9,amount:1,elite:0,allyPayment:0});
|
||||
reach(salusaGholaClean);
|
||||
}
|
||||
act(owner,{type:'endMovement'});
|
||||
}
|
||||
assert.equal(game.phase,6);
|
||||
reach(g => salusaGholaClean(g) && !g.battle && !!g.active);
|
||||
const chooser = game.active!;
|
||||
const choice = viewGame(game,chooser).battleChoices.find(c => c.territory === 'imperial_basin' &&
|
||||
[c.attacker,c.defender].includes('h') && [c.attacker,c.defender].includes('e'));
|
||||
assert.ok(choice);
|
||||
return {game,chooser,choice,action:{type:'chooseBattle',territory:choice.territory,
|
||||
target:choice.attacker === chooser ? choice.defender : choice.attacker} as Action};
|
||||
}
|
||||
|
||||
/** Replace a held card with one physical matching card from a real zone. */
|
||||
export function stageLeaderlessCard(game: Game, owner: string, effect: 'hero'|'ghola'|'none') {
|
||||
const seat = leaderlessSeat(game,owner);
|
||||
const selected = (card: {kind:string;effect?:string}) => card.kind === 'hero' || card.effect === 'ghola';
|
||||
for (let i=0; i<seat.hand.length; i++) if (selected(seat.hand[i])) {
|
||||
const j=game.deck.findIndex(card => !selected(card)); assert.ok(j >= 0);
|
||||
[seat.hand[i],game.deck[j]] = [game.deck[j],seat.hand[i]];
|
||||
}
|
||||
if (effect === 'none') return;
|
||||
const wanted = (card:{kind:string;effect?:string}) => effect === 'hero' ? card.kind === 'hero' : card.effect === 'ghola';
|
||||
const zones = [game.deck,...game.players.filter(p => p.id !== owner).map(p => p.hand),game.discard];
|
||||
for (const zone of zones) {
|
||||
const i = zone.findIndex(wanted);
|
||||
if (i < 0) continue;
|
||||
if (seat.hand.length) [seat.hand[0],zone[i]] = [zone[i],seat.hand[0]];
|
||||
else seat.hand.push(zone.splice(i,1)[0]);
|
||||
return;
|
||||
}
|
||||
assert.fail(`Physical ${effect} must be in a known zone`);
|
||||
}
|
||||
Reference in new issue
Block a user