From 478b38f5dec984fb920bf02c471e125b04405ade Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:46:15 +1100 Subject: [PATCH] Support group Harkonnen traitor calls --- components/game-table.tsx | 4 +- docs/CURRENT_STATUS.md | 13 +- docs/OPTIONAL_ALLIANCE_VICTORY.md | 10 + docs/RULE_DECISIONS.md | 5 + game/bots.ts | 4 +- game/engine.ts | 14 +- game/reference.ts | 4 +- game/traitor-declarations.ts | 8 +- tests/coalition-harkonnen-traitor.test.ts | 199 +++++++++++++++++++ tests/coalition-traitor-declarations.test.ts | 95 +++++++++ 10 files changed, 343 insertions(+), 13 deletions(-) create mode 100644 tests/coalition-harkonnen-traitor.test.ts create mode 100644 tests/coalition-traitor-declarations.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index b43c7ab3..c223b9a5 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -390,10 +390,10 @@ export function GameTable({ const canActivateKarama = (card: Card) => canSpendKarama(card, kullSpecialPurpose(me.faction)); const traitorBattle = g.battle; const traitorBeneficiary = - traitorBattle && + traitorBattle?.traitorBeneficiary ?? (traitorBattle && [traitorBattle.attacker, traitorBattle.defender].includes(me.id) ? me.id - : me.ally; + : me.ally); const traitorPlan = traitorBattle?.revealed ? traitorBattle.plans[ traitorBattle.attacker === traitorBeneficiary diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 00971c16..ccf79ad8 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,14 @@ open mode gates. ## 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.** 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 @@ -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, and published both revision and checkpoint tags with digest `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. Deployment remains unverified. Existing NAS deployment and its persistent storage remain untouched. diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md index 20a1bf86..cc54468b 100644 --- a/docs/OPTIONAL_ALLIANCE_VICTORY.md +++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md @@ -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 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 **Integrated groundwork, not a selectable game mode.** In a staged Basic diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index bc68bcf0..5609d113 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,10 @@ # 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 The staged Basic group prototype provisionally applies these native alliance diff --git a/game/bots.ts b/game/bots.ts index 3793e990..c9ce5ecf 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -4125,9 +4125,9 @@ function policyActions(g: GameView): Action[] { if (b.revealed) { const ownsTraitorDecision = 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.ally; + : me.ally); const enemy = b.attacker === beneficiary ? b.defender : b.attacker; const preferredCall = ownsTraitorDecision && diff --git a/game/engine.ts b/game/engine.ts index e7bc7289..008b8a2a 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -3939,8 +3939,12 @@ function nexusTraitorParentSignature(g: Game, event: string) { function traitorDeclarationContext(g: Game): TraitorDeclarationContext { const b = g.battle!; 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)! } : {}), - 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), }; } @@ -32545,9 +32549,10 @@ function traitorVoters(g: Game, b: Battle) { for (const owner of [b.ecazOccupy.ecaz, b.ecazOccupy.ally]) if (!voters.includes(owner)) voters.push(owner); const harkonnen = byFaction(g, 'harkonnen'); + const harkonnenBeneficiary = harkonnen && coalitionBattleBeneficiary(g, harkonnen.id, b.attacker, b.defender); if ( - harkonnen?.ally && - voters.includes(harkonnen.ally) && + harkonnen && harkonnenBeneficiary && + voters.includes(harkonnenBeneficiary) && !voters.includes(harkonnen.id) ) voters.push(harkonnen.id); @@ -32555,7 +32560,7 @@ function traitorVoters(g: Game, b: Battle) { } function traitorBeneficiary(g: Game, b: Battle, id: string) { 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. */ 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] } : {}, traitorSubmitted: Object.keys(b.traitorCalls), + traitorBeneficiary: traitorVoters(g, b).includes(id) ? traitorBeneficiary(g, b, id) : null, traitorCallChoices: (() => { const context = traitorTruthContext(g, id); return context ? turnActionTraitorChoices(g, id, traitorDecisionRule(() => traitorDecisionChoices(context, b.traitorDecisionPromises ?? []))) : null; })(), diff --git a/game/reference.ts b/game/reference.ts index e646b905..06613d35 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4500,11 +4500,11 @@ export const RULE_TOPICS: RuleTopic[] = [ 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.', 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: '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: '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: [ 'mentat', diff --git a/game/traitor-declarations.ts b/game/traitor-declarations.ts index d5b83137..1abf20f5 100644 --- a/game/traitor-declarations.ts +++ b/game/traitor-declarations.ts @@ -1,8 +1,9 @@ import { FACTIONS, type FactionId } from './catalog'; import { CHEAP_HERO_TRAITOR } from './traitors'; import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle'; +import { coalitionBattleBeneficiary, type CoalitionBattlePreparationContext } from './coalition-battle-preparation'; -export type TraitorDeclarationContext = { +export type TraitorDeclarationContext = Omit, 'players'> & { ecazOccupy?: EcazOccupyBattleProfile; event: string; attacker: string; @@ -12,6 +13,7 @@ export type TraitorDeclarationContext = { id: string; faction: FactionId; ally?: string | null; + allySinceTurn?: number; traitors: readonly string[]; }[]; heroLeaderIds: readonly string[]; @@ -98,7 +100,9 @@ function declaredElements(context: TraitorDeclarationContext, voter: string) { const combatants = [context.attacker, context.defender]; const occupy = context.ecazOccupy; 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( beneficiary && combatants.includes(beneficiary) && diff --git a/tests/coalition-harkonnen-traitor.test.ts b/tests/coalition-harkonnen-traitor.test.ts new file mode 100644 index 00000000..33429a1e --- /dev/null +++ b/tests/coalition-harkonnen-traitor.test.ts @@ -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>)[\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/); +}); diff --git a/tests/coalition-traitor-declarations.test.ts b/tests/coalition-traitor-declarations.test.ts new file mode 100644 index 00000000..e6774ed5 --- /dev/null +++ b/tests/coalition-traitor-declarations.test.ts @@ -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); +});