Support group Harkonnen traitor calls
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
83ccd71821
commit
478b38f5de
10 files changed
+343
-13
No files matched your search
@@ -390,10 +390,10 @@ export function GameTable({
|
|||||||
const canActivateKarama = (card: Card) => canSpendKarama(card, kullSpecialPurpose(me.faction));
|
const canActivateKarama = (card: Card) => canSpendKarama(card, kullSpecialPurpose(me.faction));
|
||||||
const traitorBattle = g.battle;
|
const traitorBattle = g.battle;
|
||||||
const traitorBeneficiary =
|
const traitorBeneficiary =
|
||||||
traitorBattle &&
|
traitorBattle?.traitorBeneficiary ?? (traitorBattle &&
|
||||||
[traitorBattle.attacker, traitorBattle.defender].includes(me.id)
|
[traitorBattle.attacker, traitorBattle.defender].includes(me.id)
|
||||||
? me.id
|
? me.id
|
||||||
: me.ally;
|
: me.ally);
|
||||||
const traitorPlan = traitorBattle?.revealed
|
const traitorPlan = traitorBattle?.revealed
|
||||||
? traitorBattle.plans[
|
? traitorBattle.plans[
|
||||||
traitorBattle.attacker === traitorBeneficiary
|
traitorBattle.attacker === traitorBeneficiary
|
||||||
|
|||||||
+12
-1
@@ -17,6 +17,14 @@ open mode gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
|
||||||
|
**Staged group Harkonnen traitor support — 28 October. Prototyped / Partial.**
|
||||||
|
Harkonnen can use its own traitor in either ally's battle through the native
|
||||||
|
vote, Karama and outcome flow. Applying this to two allies is provisional;
|
||||||
|
five related files pass 56 cases, including real Nexus, sealed plans, calls,
|
||||||
|
outcomes and server-rendered controls after a staged position. Other group
|
||||||
|
abilities and normal lobby access remain unfinished.
|
||||||
|
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-harkonnen-traitor-support--28-october-2026).
|
||||||
|
|
||||||
**Staged group Voice and Prescience — 28 October. Prototyped / Partial.**
|
**Staged group Voice and Prescience — 28 October. Prototyped / Partial.**
|
||||||
Bene Gesserit and Atreides can offer their native battle powers for either
|
Bene Gesserit and Atreides can offer their native battle powers for either
|
||||||
ally in a staged Basic classic group; the same beneficiary quote is used by
|
ally in a staged Basic classic group; the same beneficiary quote is used by
|
||||||
@@ -310,7 +318,10 @@ Run 12 was canceled before starting; run 21 for `1eeeb32` passed 25
|
|||||||
administrator and 69 HTTP checks, origin/restart/replacement verification,
|
administrator and 69 HTTP checks, origin/restart/replacement verification,
|
||||||
and published both revision and checkpoint tags with digest
|
and published both revision and checkpoint tags with digest
|
||||||
`sha256:fbfc252a46f2ee900e4640e1ef8ad29c0605088267b992f42fe2fa5519dc91db`.
|
`sha256:fbfc252a46f2ee900e4640e1ef8ad29c0605088267b992f42fe2fa5519dc91db`.
|
||||||
Run 22 was canceled before starting; run 23 for `9081249` was running at the
|
Run 22 was canceled before starting; run 23 for `9081249` succeeded and
|
||||||
|
published revision and checkpoint tags with digest
|
||||||
|
`sha256:ca98dec808c4e9709241c5a2e5ebca144941eb700ee538fed89904455600d886`.
|
||||||
|
Run 24 was canceled before starting; run 25 for `83ccd71` was running at the
|
||||||
last check.
|
last check.
|
||||||
Deployment remains unverified.
|
Deployment remains unverified.
|
||||||
Existing NAS deployment and its persistent storage remain untouched.
|
Existing NAS deployment and its persistent storage remain untouched.
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ single-ally wording to both group members is provisional. Advanced, expansion
|
|||||||
factions and optional modules remain outside this prototype, and normal
|
factions and optional modules remain outside this prototype, and normal
|
||||||
group starts are not yet available.
|
group starts are not yet available.
|
||||||
|
|
||||||
|
## Group Harkonnen traitor support — 28 October 2026
|
||||||
|
|
||||||
|
In the unexposed Basic classic group prototype, Harkonnen may call its held
|
||||||
|
traitor against the opponent of either allied combatant. The choice remains
|
||||||
|
Harkonnen's, and the native allied-call Karama response, other combatants'
|
||||||
|
votes, mutual calls and battle resolution continue through the existing
|
||||||
|
windows. Applying the printed single-ally power to both group members is a
|
||||||
|
provisional interpretation. Other profiles and normal lobby access remain
|
||||||
|
separate; the remaining group abilities and interactions are unfinished.
|
||||||
|
|
||||||
## Group Nexus negotiation — 28 October 2026
|
## Group Nexus negotiation — 28 October 2026
|
||||||
|
|
||||||
**Integrated groundwork, not a selectable game mode.** In a staged Basic
|
**Integrated groundwork, not a selectable game mode.** In a staged Basic
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Rule decision index
|
# Rule decision index
|
||||||
|
|
||||||
|
## Group Harkonnen traitor support — 28 October 2026
|
||||||
|
|
||||||
|
The staged Basic group prototype provisionally allows Harkonnen to support
|
||||||
|
either ally's battle with its own held traitor. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-harkonnen-traitor-support--28-october-2026).
|
||||||
|
|
||||||
## Group Voice and Prescience — 28 October 2026
|
## Group Voice and Prescience — 28 October 2026
|
||||||
|
|
||||||
The staged Basic group prototype provisionally applies these native alliance
|
The staged Basic group prototype provisionally applies these native alliance
|
||||||
|
|||||||
+2
-2
@@ -4125,9 +4125,9 @@ function policyActions(g: GameView): Action[] {
|
|||||||
if (b.revealed) {
|
if (b.revealed) {
|
||||||
const ownsTraitorDecision =
|
const ownsTraitorDecision =
|
||||||
b.traitorVoters.includes(me.id) && !b.traitorSubmitted.includes(me.id);
|
b.traitorVoters.includes(me.id) && !b.traitorSubmitted.includes(me.id);
|
||||||
const beneficiary = [b.attacker, b.defender].includes(me.id)
|
const beneficiary = b.traitorBeneficiary ?? ([b.attacker, b.defender].includes(me.id)
|
||||||
? me.id
|
? me.id
|
||||||
: me.ally;
|
: me.ally);
|
||||||
const enemy = b.attacker === beneficiary ? b.defender : b.attacker;
|
const enemy = b.attacker === beneficiary ? b.defender : b.attacker;
|
||||||
const preferredCall =
|
const preferredCall =
|
||||||
ownsTraitorDecision &&
|
ownsTraitorDecision &&
|
||||||
|
|||||||
+10
-4
@@ -3939,8 +3939,12 @@ function nexusTraitorParentSignature(g: Game, event: string) {
|
|||||||
function traitorDeclarationContext(g: Game): TraitorDeclarationContext {
|
function traitorDeclarationContext(g: Game): TraitorDeclarationContext {
|
||||||
const b = g.battle!;
|
const b = g.battle!;
|
||||||
return { event: b.event!, attacker: b.attacker, defender: b.defender, plans: b.plans,
|
return { event: b.event!, attacker: b.attacker, defender: b.defender, plans: b.plans,
|
||||||
|
status: g.status, advanced: g.advanced, coalitionState: g.coalitionState, expansions: g.expansions,
|
||||||
|
homeworlds: g.homeworlds, nexusCards: g.nexusCards, leaderSkills: g.leaderSkills,
|
||||||
|
techTokens: g.techTokens, strongholdCards: g.strongholdCards, ecazTreachery: g.ecazTreachery,
|
||||||
|
discoveryEnabled: g.discoveryEnabled,
|
||||||
...(b.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
|
...(b.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
|
||||||
players: g.players.map(({id,faction,ally,traitors}) => ({id,faction,ally,traitors})),
|
players: g.players.map(({id,faction,ally,allySinceTurn,traitors}) => ({id,faction,ally,allySinceTurn,traitors})),
|
||||||
heroLeaderIds: physicalTreacheryCards(g).filter(c => c.kind === 'hero').map(c => c.id),
|
heroLeaderIds: physicalTreacheryCards(g).filter(c => c.kind === 'hero').map(c => c.id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -32545,9 +32549,10 @@ function traitorVoters(g: Game, b: Battle) {
|
|||||||
for (const owner of [b.ecazOccupy.ecaz, b.ecazOccupy.ally])
|
for (const owner of [b.ecazOccupy.ecaz, b.ecazOccupy.ally])
|
||||||
if (!voters.includes(owner)) voters.push(owner);
|
if (!voters.includes(owner)) voters.push(owner);
|
||||||
const harkonnen = byFaction(g, 'harkonnen');
|
const harkonnen = byFaction(g, 'harkonnen');
|
||||||
|
const harkonnenBeneficiary = harkonnen && coalitionBattleBeneficiary(g, harkonnen.id, b.attacker, b.defender);
|
||||||
if (
|
if (
|
||||||
harkonnen?.ally &&
|
harkonnen && harkonnenBeneficiary &&
|
||||||
voters.includes(harkonnen.ally) &&
|
voters.includes(harkonnenBeneficiary) &&
|
||||||
!voters.includes(harkonnen.id)
|
!voters.includes(harkonnen.id)
|
||||||
)
|
)
|
||||||
voters.push(harkonnen.id);
|
voters.push(harkonnen.id);
|
||||||
@@ -32555,7 +32560,7 @@ function traitorVoters(g: Game, b: Battle) {
|
|||||||
}
|
}
|
||||||
function traitorBeneficiary(g: Game, b: Battle, id: string) {
|
function traitorBeneficiary(g: Game, b: Battle, id: string) {
|
||||||
if (b.ecazOccupy?.lead && [b.ecazOccupy.ecaz, b.ecazOccupy.ally].includes(id)) return b.ecazOccupy.lead;
|
if (b.ecazOccupy?.lead && [b.ecazOccupy.ecaz, b.ecazOccupy.ally].includes(id)) return b.ecazOccupy.lead;
|
||||||
return [b.attacker, b.defender].includes(id) ? id : getPlayer(g, id).ally;
|
return coalitionBattleBeneficiary(g, id, b.attacker, b.defender);
|
||||||
}
|
}
|
||||||
/** One native eligibility quote for declarations and private Truthtrance answers. */
|
/** One native eligibility quote for declarations and private Truthtrance answers. */
|
||||||
function traitorCallBlock(g: Game, b: Battle, id: string, held = getPlayer(g, id).traitors): string | null {
|
function traitorCallBlock(g: Game, b: Battle, id: string, held = getPlayer(g, id).traitors): string | null {
|
||||||
@@ -47453,6 +47458,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
|||||||
? { [id]: b.plans[id] }
|
? { [id]: b.plans[id] }
|
||||||
: {},
|
: {},
|
||||||
traitorSubmitted: Object.keys(b.traitorCalls),
|
traitorSubmitted: Object.keys(b.traitorCalls),
|
||||||
|
traitorBeneficiary: traitorVoters(g, b).includes(id) ? traitorBeneficiary(g, b, id) : null,
|
||||||
traitorCallChoices: (() => { const context = traitorTruthContext(g, id); return context
|
traitorCallChoices: (() => { const context = traitorTruthContext(g, id); return context
|
||||||
? turnActionTraitorChoices(g, id,
|
? turnActionTraitorChoices(g, id,
|
||||||
traitorDecisionRule(() => traitorDecisionChoices(context, b.traitorDecisionPromises ?? []))) : null; })(),
|
traitorDecisionRule(() => traitorDecisionChoices(context, b.traitorDecisionPromises ?? []))) : null; })(),
|
||||||
|
|||||||
+2
-2
@@ -4500,11 +4500,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
example:
|
example:
|
||||||
'At the final turn with neither Guild nor Fremen, two players each hold two qualifying strongholds and a third holds one. The two leaders of the count share the win.',
|
'At the final turn with neither Guild nor Fremen, two players each hold two qualifying strongholds and a third holds one. The two leaders of the count share the win.',
|
||||||
checklist: [
|
checklist: [
|
||||||
{ area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding, Emperor and Fremen aid, and native Voice and Prescience; other group benefits remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-battle-preparation.ts', 'game/terminal-cancellation.ts'] },
|
{ area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding, Emperor and Fremen aid, Voice, Prescience and Harkonnen traitor support; other group benefits remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] },
|
||||||
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
|
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
|
||||||
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
|
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
|
||||||
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
|
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
|
||||||
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, Emperor and Fremen aid and battle preparation; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts'] },
|
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, Emperor and Fremen aid, battle preparation and allied traitor votes; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
|
||||||
],
|
],
|
||||||
related: [
|
related: [
|
||||||
'mentat',
|
'mentat',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { FACTIONS, type FactionId } from './catalog';
|
import { FACTIONS, type FactionId } from './catalog';
|
||||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||||
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
|
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
|
||||||
|
import { coalitionBattleBeneficiary, type CoalitionBattlePreparationContext } from './coalition-battle-preparation';
|
||||||
|
|
||||||
export type TraitorDeclarationContext = {
|
export type TraitorDeclarationContext = Omit<Partial<CoalitionBattlePreparationContext>, 'players'> & {
|
||||||
ecazOccupy?: EcazOccupyBattleProfile;
|
ecazOccupy?: EcazOccupyBattleProfile;
|
||||||
event: string;
|
event: string;
|
||||||
attacker: string;
|
attacker: string;
|
||||||
@@ -12,6 +13,7 @@ export type TraitorDeclarationContext = {
|
|||||||
id: string;
|
id: string;
|
||||||
faction: FactionId;
|
faction: FactionId;
|
||||||
ally?: string | null;
|
ally?: string | null;
|
||||||
|
allySinceTurn?: number;
|
||||||
traitors: readonly string[];
|
traitors: readonly string[];
|
||||||
}[];
|
}[];
|
||||||
heroLeaderIds: readonly string[];
|
heroLeaderIds: readonly string[];
|
||||||
@@ -98,7 +100,9 @@ function declaredElements(context: TraitorDeclarationContext, voter: string) {
|
|||||||
const combatants = [context.attacker, context.defender];
|
const combatants = [context.attacker, context.defender];
|
||||||
const occupy = context.ecazOccupy;
|
const occupy = context.ecazOccupy;
|
||||||
const coSide = !!occupy && [occupy.ecaz, occupy.ally].includes(voter);
|
const coSide = !!occupy && [occupy.ecaz, occupy.ally].includes(voter);
|
||||||
const beneficiary = coSide ? occupy.lead : combatants.includes(voter) ? voter : owner.ally;
|
const beneficiary = coSide ? occupy.lead : combatants.includes(voter) ? voter :
|
||||||
|
owner.faction === 'harkonnen' ? coalitionBattleBeneficiary(
|
||||||
|
{...context, status: context.status ?? ''}, voter, context.attacker, context.defender) : null;
|
||||||
requireDeclaration(
|
requireDeclaration(
|
||||||
beneficiary &&
|
beneficiary &&
|
||||||
combatants.includes(beneficiary) &&
|
combatants.includes(beneficiary) &&
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { registerHooks } from 'node:module';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { baseDeck } from '../game/cards';
|
||||||
|
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { applyAction, battles, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { traitorDeck } from '../game/traitors';
|
||||||
|
|
||||||
|
const aliases = registerHooks({
|
||||||
|
resolve(specifier, context, next) {
|
||||||
|
return next(specifier === 'next/image' ? 'vinext/shims/image' :
|
||||||
|
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||||
|
},
|
||||||
|
load(url, context, next) {
|
||||||
|
if (!url.endsWith('.module.css')) return next(url, context);
|
||||||
|
const css = readFileSync(new URL(url), 'utf8');
|
||||||
|
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||||
|
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const {GameTable} = await import('../components/game-table');
|
||||||
|
aliases.deregister();
|
||||||
|
|
||||||
|
const send = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||||
|
const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!;
|
||||||
|
const physicalTraitors = (game: Game) => [
|
||||||
|
...(game.traitorReserve ?? []), ...game.players.flatMap(seat => [...seat.traitors, ...seat.traitorChoices]),
|
||||||
|
].sort();
|
||||||
|
|
||||||
|
/** Three-member groups cannot be started in the lobby. Stage a Nexus, form
|
||||||
|
* the pair and group through actual actions, then stage conserved forces in
|
||||||
|
* Arrakeen and transfer physical Traitor identities out of their reserve. */
|
||||||
|
function source(ally: 'e' | 'f' | 'h', options: { group?: boolean; mutual?: boolean } = {}) {
|
||||||
|
let game = createGame('GROUPTRAITOR', newPlayer('h', 'Harkonnen', 'harkonnen'), false);
|
||||||
|
game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'),
|
||||||
|
newPlayer('g', 'Guild', 'guild'));
|
||||||
|
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['h','e','f','g'],
|
||||||
|
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
|
||||||
|
game.coalitionState = emptyCoalitionState();
|
||||||
|
game = send(game, 'h', {type:'alliance', target:'e'});
|
||||||
|
game = send(game, 'e', {type:'alliance', target:'h'});
|
||||||
|
if (options.group !== false) {
|
||||||
|
const pair = coalitionOf(game.players, game.coalitionState, 'h'); assert.ok(pair);
|
||||||
|
game = send(game, 'h', {type:'coalitionPropose', groupId:pair.id, entrant:'f',
|
||||||
|
expectedRevision:game.coalitionState!.revision});
|
||||||
|
for (const id of ['e','f']) {
|
||||||
|
const proposal = game.coalitionState!.proposal!;
|
||||||
|
game = send(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
|
||||||
|
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||||
|
}
|
||||||
|
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'h')!.members, ['e','f','h']);
|
||||||
|
}
|
||||||
|
Object.assign(game, {phase:6, nexus:false, ready:[], response:null, decision:null,
|
||||||
|
phaseOpening:null, battle:null});
|
||||||
|
for (const seat of game.players) {
|
||||||
|
seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10;
|
||||||
|
seat.hand = []; seat.traitors = []; seat.traitorChoices = [];
|
||||||
|
}
|
||||||
|
for (const id of [ally,'g']) {
|
||||||
|
player(game, id).forces = {'arrakeen:10':4};
|
||||||
|
player(game, id).reserves = 16;
|
||||||
|
}
|
||||||
|
const opponentLeader = player(game, 'g').leaders[0].id;
|
||||||
|
const allyLeader = player(game, ally).leaders[0].id;
|
||||||
|
game.traitorReserve = traitorDeck(game.players);
|
||||||
|
const inventory = physicalTraitors(game);
|
||||||
|
const transfer = (identity: string, holder: string) => {
|
||||||
|
const index = game.traitorReserve!.indexOf(identity); assert.ok(index >= 0);
|
||||||
|
player(game, holder).traitors.push(...game.traitorReserve!.splice(index, 1));
|
||||||
|
};
|
||||||
|
transfer(opponentLeader, 'h');
|
||||||
|
if (options.mutual) transfer(allyLeader, 'g');
|
||||||
|
assert.deepEqual(physicalTraitors(game), inventory);
|
||||||
|
const karamaIndex = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(karamaIndex >= 0);
|
||||||
|
const [karama] = game.deck.splice(karamaIndex, 1); player(game, 'g').hand.push(karama);
|
||||||
|
const choice = battles(game).find(candidate => candidate.territory === 'arrakeen' &&
|
||||||
|
[candidate.attacker,candidate.defender].includes(ally) && [candidate.attacker,candidate.defender].includes('g'));
|
||||||
|
assert.ok(choice);
|
||||||
|
game.active = choice.chooser;
|
||||||
|
const enemy = choice.attacker === choice.chooser ? choice.defender : choice.attacker;
|
||||||
|
game = send(game, choice.chooser, {type:'chooseBattle', territory:'arrakeen', target:enemy});
|
||||||
|
assert.ok(game.battle && !game.battle.preparation);
|
||||||
|
game = send(game, ally, {type:'battlePlan', leader:allyLeader, dial:0, support:0, weapon:null, defense:null});
|
||||||
|
assert.ok(!game.battle?.revealed);
|
||||||
|
game = send(game, 'g', {type:'battlePlan', leader:opponentLeader, dial:0, support:0, weapon:null, defense:null});
|
||||||
|
assert.equal(game.battle?.revealed, true);
|
||||||
|
return {game, ally, opponentLeader, allyLeader, karama, inventory};
|
||||||
|
}
|
||||||
|
function passResponses(game: Game): Game {
|
||||||
|
for (let count = 0; game.response && count < 24; count++) {
|
||||||
|
const next = game.players.find(seat => !game.response!.passed.includes(seat.id));
|
||||||
|
assert.ok(next);
|
||||||
|
game = send(game, next.id, {type:'passResponse'});
|
||||||
|
}
|
||||||
|
assert.equal(game.response ?? null, null);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
function conserved(game: Game) {
|
||||||
|
for (const seat of game.players)
|
||||||
|
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((a,b) => a+b, 0), 20);
|
||||||
|
}
|
||||||
|
function reject(game: Game, id: string, action: Action) {
|
||||||
|
const before = structuredClone(game);
|
||||||
|
assert.throws(() => send(game, id, action));
|
||||||
|
assert.deepEqual(game, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ally of ['e','f'] as const) {
|
||||||
|
void test(`Harkonnen may reveal its Guild traitor for group ally ${ally}`, () => {
|
||||||
|
const f = source(ally); let game = f.game;
|
||||||
|
const own = viewGame(game, 'h').battle!;
|
||||||
|
assert.ok(own.traitorVoters.includes('h'));
|
||||||
|
assert.equal(own.traitorBeneficiary, ally);
|
||||||
|
assert.ok(own.traitorCallChoices?.includes(true));
|
||||||
|
game = send(game, 'h', {type:'traitorCall', call:true});
|
||||||
|
assert.equal(game.response?.kind, 'harkonnenTraitor');
|
||||||
|
assert.equal(game.battle?.traitorCalls.h, true);
|
||||||
|
game = passResponses(game);
|
||||||
|
game = send(game, ally, {type:'traitorCall', call:false});
|
||||||
|
game = send(game, 'g', {type:'traitorCall', call:false});
|
||||||
|
assert.equal(game.lastBattleContext?.result, 'traitor');
|
||||||
|
assert.equal(game.lastBattleContext?.winner, ally);
|
||||||
|
assert.equal(player(game, 'g').leaders.find(leader => leader.id === f.opponentLeader)?.dead, true);
|
||||||
|
assert.deepEqual(physicalTraitors(game), f.inventory);
|
||||||
|
conserved(game);
|
||||||
|
});
|
||||||
|
void test(`Karama cancels Harkonnen's group traitor call for ${ally}`, () => {
|
||||||
|
const f = source(ally); let game = send(f.game, 'h', {type:'traitorCall', call:true});
|
||||||
|
assert.equal(game.response?.kind, 'harkonnenTraitor');
|
||||||
|
game = send(game, 'g', {type:'card', mode:'cancel', card:f.karama.id});
|
||||||
|
game = passResponses(game);
|
||||||
|
assert.equal(game.battle?.traitorCalls.h, false);
|
||||||
|
reject(game, 'h', {type:'traitorCall', call:true});
|
||||||
|
game = send(game, ally, {type:'traitorCall', call:false});
|
||||||
|
game = send(game, 'g', {type:'traitorCall', call:false});
|
||||||
|
assert.equal(game.lastBattleContext?.result, 'normal');
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.karama.id).length, 1);
|
||||||
|
assert.deepEqual(physicalTraitors(game), f.inventory);
|
||||||
|
conserved(game);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('an opposing Guild call and Harkonnen group call destroy both sides', () => {
|
||||||
|
const f = source('f', {mutual:true}); let game = send(f.game, 'g', {type:'traitorCall', call:true});
|
||||||
|
game = send(game, 'h', {type:'traitorCall', call:true});
|
||||||
|
game = passResponses(game);
|
||||||
|
game = send(game, 'f', {type:'traitorCall', call:false});
|
||||||
|
assert.equal(game.lastBattleContext?.result, 'mutualTraitors');
|
||||||
|
assert.equal(player(game, 'g').leaders.find(leader => leader.id === f.opponentLeader)?.dead, true);
|
||||||
|
assert.equal(player(game, 'f').leaders.find(leader => leader.id === f.allyLeader)?.dead, true);
|
||||||
|
assert.deepEqual(physicalTraitors(game), f.inventory);
|
||||||
|
conserved(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('the existing two-member Harkonnen ally call keeps its implicit beneficiary', () => {
|
||||||
|
const f = source('e', {group:false});
|
||||||
|
assert.equal(viewGame(f.game, 'h').battle?.traitorBeneficiary, 'e');
|
||||||
|
const game = send(f.game, 'h', {type:'traitorCall', call:true});
|
||||||
|
assert.equal(game.response?.kind, 'harkonnenTraitor');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Harkonnen still calls its own traitor as a combatant in a group', () => {
|
||||||
|
const f = source('h');
|
||||||
|
assert.equal(viewGame(f.game, 'h').battle?.traitorBeneficiary, 'h');
|
||||||
|
const game = send(f.game, 'h', {type:'traitorCall', call:true});
|
||||||
|
assert.equal(game.battle?.traitorCalls.h, true);
|
||||||
|
assert.notEqual(game.response?.kind, 'harkonnenTraitor');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a Medium Harkonnen bot offers a legal allied group traitor call', () => {
|
||||||
|
const {game} = source('f');
|
||||||
|
const view = viewGame(game, 'h');
|
||||||
|
view.players.find(seat => seat.id === 'h')!.bot = 'Medium';
|
||||||
|
const action = botActions(view).find(candidate => candidate.type === 'traitorCall');
|
||||||
|
assert.ok(action); assert.equal(action.call, true);
|
||||||
|
assert.equal(send(game, 'h', action).response?.kind, 'harkonnenTraitor');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('the actual group table gives Harkonnen an enabled allied call without revealing its hand to the other ally', () => {
|
||||||
|
const {game, opponentLeader} = source('e');
|
||||||
|
const own = viewGame(game, 'h');
|
||||||
|
const ownMarkup = renderToStaticMarkup(createElement(GameTable, {
|
||||||
|
game:own, busy:false, send:async () => {}, onExit() {},
|
||||||
|
}));
|
||||||
|
const button = ownMarkup.match(/<button\b[^>]*>(?:(?!<\/button>)[\s\S])*Reveal ally’s opponent as traitor(?:(?!<\/button>)[\s\S])*<\/button>/)?.[0];
|
||||||
|
assert.ok(button, 'The allied call is offered to Harkonnen.');
|
||||||
|
assert.doesNotMatch(button, /\bdisabled(?:=|\s|>)/);
|
||||||
|
assert.ok(own.players.find(seat => seat.id === 'h')?.traitors?.includes(opponentLeader));
|
||||||
|
const observer = viewGame(game, 'f');
|
||||||
|
assert.equal(observer.players.find(seat => seat.id === 'h')?.traitors, undefined);
|
||||||
|
const observerMarkup = renderToStaticMarkup(createElement(GameTable, {
|
||||||
|
game:observer, busy:false, send:async () => {}, onExit() {},
|
||||||
|
}));
|
||||||
|
assert.doesNotMatch(observerMarkup, /Reveal ally’s opponent as traitor/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||||
|
import { applyAction, createGame, newPlayer } from '../game/engine';
|
||||||
|
import { createTraitorDeclaration, validateTraitorDeclaration, validateTraitorDeclarations,
|
||||||
|
type TraitorDeclarationContext } from '../game/traitor-declarations';
|
||||||
|
|
||||||
|
/** The ledger is staged because the lobby does not yet expose group play.
|
||||||
|
* Pair formation and unanimous admission use real Nexus actions. The battle
|
||||||
|
* plans and Harkonnen's known identity are staged for this receipt quote. */
|
||||||
|
function source(group = true): TraitorDeclarationContext {
|
||||||
|
let game = createGame('GROUPTRAITOR', newPlayer('h', 'Harkonnen', 'harkonnen'), false);
|
||||||
|
game.players.push(newPlayer('a', 'Atreides', 'atreides'),
|
||||||
|
newPlayer('e', 'Emperor', 'emperor'), newPlayer('g', 'Guild', 'guild'));
|
||||||
|
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true,
|
||||||
|
order:['h','a','e','g'], ready:[], storm:18, response:null, decision:null, phaseOpening:null});
|
||||||
|
game.coalitionState = emptyCoalitionState();
|
||||||
|
game = applyAction(game, 'h', {type:'alliance', target:'a'});
|
||||||
|
game = applyAction(game, 'a', {type:'alliance', target:'h'});
|
||||||
|
if (group) {
|
||||||
|
const pair = coalitionOf(game.players, game.coalitionState, 'h'); assert.ok(pair);
|
||||||
|
game = applyAction(game, 'h', {type:'coalitionPropose', groupId:pair.id, entrant:'e',
|
||||||
|
expectedRevision:game.coalitionState!.revision});
|
||||||
|
for (const id of ['a','e']) {
|
||||||
|
const proposal = game.coalitionState!.proposal!;
|
||||||
|
game = applyAction(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
|
||||||
|
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||||
|
}
|
||||||
|
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'h')!.members, ['a','e','h']);
|
||||||
|
}
|
||||||
|
return {event:'group-battle', attacker:'a', defender:'g',
|
||||||
|
status:'playing', advanced:false, expansions:[], coalitionState:game.coalitionState,
|
||||||
|
plans:{a:{leader:'a-leader'}, e:{leader:'e-leader'}, g:{leader:'g-leader'}},
|
||||||
|
players:game.players.map(player => ({id:player.id, faction:player.faction, ally:player.ally,
|
||||||
|
...(player.allySinceTurn === undefined ? {} : {allySinceTurn:player.allySinceTurn}),
|
||||||
|
traitors:player.id === 'h' ? ['g-leader'] : []})), heroLeaderIds:[]};
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('a Harkonnen group declaration binds each actual ally, opponent and identity', () => {
|
||||||
|
for (const ally of ['a','e']) {
|
||||||
|
const game = source();
|
||||||
|
game.attacker = ally;
|
||||||
|
const before = structuredClone(game);
|
||||||
|
const receipt = createTraitorDeclaration(game, 'h');
|
||||||
|
assert.deepEqual({voter:receipt.voter, beneficiary:receipt.beneficiary, target:receipt.target,
|
||||||
|
leader:receipt.leader, identity:receipt.identity},
|
||||||
|
{voter:'h', beneficiary:ally, target:'g', leader:'g-leader', identity:'g-leader'});
|
||||||
|
validateTraitorDeclaration(game, receipt);
|
||||||
|
validateTraitorDeclarations(game, {h:true}, {h:receipt}, 1);
|
||||||
|
assert.deepEqual(game, before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a group declaration rejects retargeted battle, leader, identity and receipt', () => {
|
||||||
|
const game = source();
|
||||||
|
const receipt = createTraitorDeclaration(game, 'h');
|
||||||
|
for (const mutate of [
|
||||||
|
(value: TraitorDeclarationContext) => { value.attacker = 'e'; },
|
||||||
|
(value: TraitorDeclarationContext) => { value.defender = 'e'; },
|
||||||
|
(value: TraitorDeclarationContext) => { value.plans.g.leader = 'replacement'; },
|
||||||
|
(value: TraitorDeclarationContext) => { value.plans.g.kwisatz = true; },
|
||||||
|
(value: TraitorDeclarationContext) => { value.event = 'another-battle'; },
|
||||||
|
]) {
|
||||||
|
const changed = structuredClone(game);
|
||||||
|
mutate(changed);
|
||||||
|
assert.throws(() => validateTraitorDeclaration(changed, receipt));
|
||||||
|
}
|
||||||
|
for (const field of ['beneficiary','target','leader','identity','signature'] as const) {
|
||||||
|
const changed = {...receipt, [field]:'incorrect'};
|
||||||
|
assert.throws(() => validateTraitorDeclaration(game, changed));
|
||||||
|
}
|
||||||
|
const noIdentity = structuredClone(game);
|
||||||
|
noIdentity.players.find(player => player.id === 'h')!.traitors = [];
|
||||||
|
assert.throws(() => createTraitorDeclaration(noIdentity, 'h'));
|
||||||
|
validateTraitorDeclaration(noIdentity, receipt);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('group declaration is unavailable in unsupported profiles or ambiguous group battles', () => {
|
||||||
|
const game = source();
|
||||||
|
for (const patch of [{advanced:true}, {expansions:['ix']}, {homeworlds:{}},
|
||||||
|
{nexusCards:{}}, {leaderSkills:{}}, {techTokens:{}}, {strongholdCards:{}},
|
||||||
|
{ecazTreachery:true}, {discoveryEnabled:true}]) {
|
||||||
|
assert.throws(() => createTraitorDeclaration({...game, ...patch}, 'h'));
|
||||||
|
}
|
||||||
|
const ambiguous = {...game, defender:'e'};
|
||||||
|
assert.throws(() => createTraitorDeclaration(ambiguous, 'h'));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('the legacy Harkonnen pair retains implicit allied beneficiary', () => {
|
||||||
|
const game = source(false);
|
||||||
|
const receipt = createTraitorDeclaration(game, 'h');
|
||||||
|
assert.equal(receipt.beneficiary, 'a');
|
||||||
|
assert.equal(receipt.target, 'g');
|
||||||
|
validateTraitorDeclaration(game, receipt);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user