Support group Harkonnen traitor calls
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-28 23:46:15 +11:00
1 parent 83ccd71821
commit 478b38f5de
10 files changed
+343 -13

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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.
+10
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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',
+6 -2
View File
@@ -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) &&
+199
View File
@@ -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);
});