diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e39a4234..9e2f7b6f 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,12 +17,25 @@ open mode gates. ## Current checkpoint and work +**Staged joint group commitments — 29 October. Prototyped / Partial.** +The joint planner can retain matching group membership or quote a member's own +departure, including payment to a former ally after leaving. An invitation +pauses joint reconciliation; compatible admission checks combined payment and +travel costs against the same purse. Thirteen related files pass 58 cases, +including real answers, admission, departure, payments and a constrained shared +wallet. Typecheck, lint and the production build pass. Two older whole-turn +affordability cases were corrected: in their fixture, an 11-spice payment can +use an earlier payment and later Charity, while 13 cannot. Actual +whole-turn group changes, member-commitment funding, other profiles and lobby +entry remain unfinished. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-admission-with-joint-commitments--29-october-2026). + **Staged group admission with live commitments — 29 October. Prototyped / Partial.** Each proposer and accepting member is checked against their own bribe and alliance answers; unrelated participants can still negotiate and bots decline -blocked consent. Pending invitations preserve alliance answers. Admission is -fenced when the older whole-turn planner could misread combined alliance and -payment/travel answers as a pair-only state. Fourteen related files pass 82 +blocked consent. Pending invitations preserve alliance answers. Admission +was initially fenced when the older whole-turn planner could misread combined +alliance and payment/travel answers as a pair-only state; the later prototype +above narrows that fence. Fourteen related files pass 82 cases, and the joint-planner regression passes separately. Typecheck, lint and the production build pass. Group funding with member commitments, whole-turn Nexus changes, other profiles and lobby entry diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md index 7dbc6478..8b9b527c 100644 --- a/docs/OPTIONAL_ALLIANCE_VICTORY.md +++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md @@ -1,5 +1,18 @@ # Optional three-stronghold alliance victory +## Group admission with joint commitments — 29 October 2026 + +**Staged Basic prototype, not a selectable game mode.** The joint +turn-action planner recognizes an existing group and can quote the member's +own departure. Pending invitations pause joint reconciliation while departure +is unavailable. Admission checks a detached completed group against the +participant's combined accepted payment and travel answers, so commitments +continue to share the actual resources. Admission requiring departure remains +available when its joint witness can quote that owned departure. Payment to a +former ally can be quoted after leaving. Actual whole-turn group changes, +group funding with member commitments, wider +profiles and lobby entry remain unfinished. + ## Group admission with live commitments — 29 October 2026 **Staged Basic prototype, not a selectable game mode.** A proposer or @@ -14,12 +27,11 @@ decline when their own acceptance is blocked. The pending invitation can be declined, and alliance answers are not released merely because it temporarily prevents departure. Unanimous consent remains a provisional online procedure. -Admission stays fenced for a participant with both an alliance answer and an -unresolved payment or travel-family answer when the older whole-turn planner -is present: that planner still assumes pairs. Group funding with a member's -commitment, whole-turn Nexus changes, wider profiles and lobby entry remain -unfinished. These focused staged cases do not certify complete group play or -expansion combinations. +Admission originally stayed fenced for a participant with both an alliance +answer and an unresolved payment or travel-family answer when the older +whole-turn planner was present; the later group-aware route above narrows +that fence. These staged cases do not certify complete group play or expansion +combinations. ## Group departures and unrelated commitments — 29 October 2026 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 4837408d..bb6ff3b2 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,13 @@ # Rule decision index +## Group admission with joint commitments — 29 October 2026 + +Staged Basic admission can retain a matching group alliance answer alongside +jointly feasible payment or travel answers. Joint planning can quote owned +group departure outside a pending invitation, including a later payment to a +former ally. Whole-turn group changes remain fenced. +[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-admission-with-joint-commitments--29-october-2026). + ## Group admission with live commitments — 29 October 2026 Staged Basic group admission checks each proposer's or accepting participant's diff --git a/game/engine.ts b/game/engine.ts index 1d0d8e75..a5a4efc7 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -16063,12 +16063,6 @@ function coalitionAdmissionAllowed(g: Game, actor: string, groupId: string, entr const unresolved = (row: {player:string; turn:number; fulfilled?:boolean; released?:boolean}) => row.player === actor && !row.fulfilled && !row.released; if ([...bribes, ...alliances].some(row => unresolved(row) && row.turn !== g.turn)) return false; - // The joint turn-action reconciler still models alliances as pairs. Do not - // let it release another accepted commitment after this player joins a - // group until that planner can represent group membership and departure. - if (g.turnActionPromises && alliances.some(unresolved) && - [g.bribePromises, g.bribeExpressionPromises, g.travelPromises, g.shipmentPromises, - g.movementPromises, g.movementExpressionPromises].some(rows => rows?.some(unresolved))) return false; if (!bribes.some(row => unresolved(row) && row.answer) && !alliances.some(unresolved)) return true; try { const trial = structuredClone(g); @@ -16095,6 +16089,10 @@ function coalitionAdmissionAllowed(g: Game, actor: string, groupId: string, entr requireRule(!trial.coalitionState!.proposal, 'The proposed group did not finish.'); trial.truthtrance = null; const player = getPlayer(trial, actor); + const joint = !!g.turnActionPromises && alliances.some(unresolved) && + [g.bribePromises, g.bribeExpressionPromises, g.travelPromises, g.shipmentPromises, + g.movementPromises, g.movementExpressionPromises].some(rows => rows?.some(unresolved)); + if (joint && !turnActionCompletion(trial, actor)) return false; return (!hasBribePromises(trial, actor, true) || !!bribePromiseCompletion(trial, player)) && (!alliances.some(unresolved) || !!alliancePromisePlan(trial, player)) && (!publicDealCompletion(g, getPlayer(g, actor)) || publicDealCompletion(trial, player)); @@ -21663,8 +21661,10 @@ function initializeTurnActionOrder(g: Game) { } /** All admissible final native partners use the original reciprocal quote. * Exact outcome rows live only in this witness; no route is persisted. */ -type TurnActionAllianceRoute = { steps: { player: string; target: string | null }[]; uncertain: boolean }; -function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false): (TurnActionAllianceRoute | undefined)[] { +type TurnActionAllianceRoute = { steps: ({ player: string; target: string | null; kind?: 'pair' } | + { player: string; kind: 'groupLeave'; revision: number })[]; uncertain: boolean }; +function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false, + allowGroupLeave = true): (TurnActionAllianceRoute | undefined)[] { const scalars = g.alliancePromises ?? [], groups = g.allianceExpressionPromises ?? []; const active = [...scalars, ...groups].some(promise => promise.player === actor && promise.turn === g.turn && !promise.fulfilled && !promise.released); @@ -21673,6 +21673,21 @@ function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false) if (!opportunity) return []; if ([...scalars, ...groups].some(promise => promise.player === actor && promise.turn === g.turn && !promise.fulfilled && !promise.released && promise.source !== opportunity.source)) return []; + const member = getPlayer(g, actor); + const coalition = g.coalitionState && coalitionRule(() => coalitionOf(g.players, g.coalitionState, actor)); + if (coalition && coalition.members.length >= 3) { + if (groupAllianceAnswersMatch(g, member, opportunity, scalars, groups)) + return [{steps: [], uncertain: false}]; + if (g.coalitionState!.proposal || !allowGroupLeave) return []; + const trial = structuredClone(g); + try { + applyCoalitionTransition(trial, coalitionRule(() => leaveCoalition(trial.players, trial.coalitionState, + {actor, expectedRevision:trial.coalitionState!.revision}))); + return groupAllianceAnswersMatch(trial, getPlayer(trial, actor), opportunity, scalars, groups) + ? [{steps: [{player: actor, kind: 'groupLeave', revision: g.coalitionState!.revision}], uncertain: false}] + : []; + } catch (error) { if (error instanceof RuleError) return []; throw error; } + } const outcomes = allianceExpressionOutcomes(scalars, groups, actor, opportunity, { players: g.players, offers: g.allianceOffers }); const plans: TurnActionAllianceRoute[] = [], seen = new Set(); @@ -21964,7 +21979,8 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn const power = turnActionPowerCompletion(trial, actor, leaves.filter((leaf): leaf is TurnActionPowerRequirement => leaf.kind === 'battlePower')); if (!power) return null; - const alliancePlans = turnActionAlliancePlans(trial, actor, !!unknownConsent); + const alliancePlans = turnActionAlliancePlans(trial, actor, !!unknownConsent, + !unsettledPromises(source.turnActionPromises)); for (const required of bribePromiseRule(() => bribeCompletionCandidates( trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) { // A quoted payment follows only a currently owned modal continuation; @@ -21977,12 +21993,28 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn if (required.length && bribeTimingBlock(paymentWindow)) continue; const total = required.reduce((sum, payment) => sum + payment.amount, 0); if (!Number.isSafeInteger(total) || - required.some(payment => payment.recipient === actor || bribeAllied(trial, owner, payment.recipient) || + required.some(payment => payment.recipient === actor || !trial.players.some(player => player.id === payment.recipient))) continue; for (const alliancePlan of alliancePlans) { try { + const departure = alliancePlan?.steps.find(step => step.kind === 'groupLeave'); + const leaveFirst = required.some(payment => bribeAllied(trial, owner, payment.recipient)); + if (leaveFirst && !departure) continue; + const paymentSource = leaveFirst ? structuredClone(trial) : trial; + if (leaveFirst && departure) { + applyCoalitionTransition(paymentSource, coalitionRule(() => leaveCoalition( + paymentSource.players, paymentSource.coalitionState, + {actor:departure.player, expectedRevision:departure.revision}))); + if (required.some(payment => bribeAllied(paymentSource, getPlayer(paymentSource, actor), payment.recipient))) continue; + } const prepareAlliance = (paid: Game) => { for (const action of alliancePlan?.steps ?? []) { + if (action.kind === 'groupLeave') { + if (leaveFirst) continue; + applyCoalitionTransition(paid, coalitionRule(() => leaveCoalition(paid.players, paid.coalitionState, + {actor:action.player, expectedRevision:action.revision}))); + continue; + } const quote = normalNexusAllianceQuote(paid, getPlayer(paid, action.player), action.target); for (const player of paid.players) player.ally = quote.allies[player.id]; paid.allianceOffers = quote.offers; @@ -22000,7 +22032,7 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn } }; const voluntary = { actor, action: { type: 'bribe' } }; - for (const cardSeed of turnActionFundedPreparations(trial, actor, required, cardRequirements, + for (const cardSeed of turnActionFundedPreparations(paymentSource, actor, required, cardRequirements, source.phaseOpening ? windowRequirements.filter(required => required.effect !== 'amal') : windowRequirements, prepareAlliance)) { const funded = cardSeed.game, payer = getPlayer(funded, actor); try { @@ -22051,7 +22083,9 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn ...(cardSeed.cardsFirst ? { paymentsAfterPreparations: true } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}), ...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}), ...(power.action ? { battlePower: power.action } : {}), - ...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}), + ...(alliancePlan ? { alliance: alliancePlan.steps.map(step => step.kind === 'groupLeave' + ? {type: 'coalitionLeave', expectedRevision: step.revision} + : { type: 'alliance', target: step.target }) } : {}), payments: cardSeed.payments }; } } catch (error) { if (!(error instanceof RuleError)) throw error; } @@ -22259,7 +22293,7 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s requireRule(actor !== promise.player || !promise.answer, 'You cannot voluntarily evade your Truthtrance bribe answer.'); if (promise.answer) promise.released = true; else promise.fulfilled = true; } - if (!turnActionPromiseTurn(g) || g.truthtrance) return; + if (!turnActionPromiseTurn(g) || g.truthtrance || g.coalitionState?.proposal) return; const nexus = allianceOpportunity(g); if (nexus && action?.type === 'bribe' && g.turnActionPromises!.some(promise => promise.player === actor && promise.turn === g.turn && promise.alliance?.source === nexus.source)) diff --git a/game/reference.ts b/game/reference.ts index c05a4fe7..808c0d5f 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4505,7 +4505,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { 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, admission with live bribe and alliance answers, four-member unlimited admission, funding and victory, staged larger-group faction helpers, funding with outsider-only commitments, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/coalition-bribe-admission.test.ts', 'tests/coalition-admission-deal-truth.test.ts', 'tests/coalition-admission-joint-commitments.test.ts', 'tests/coalition-alliance-admission-commitments.test.ts', 'tests/coalition-admission-bribe-controls.test.tsx', 'tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-truth-alliance.test.ts', 'tests/coalition-truth-alliance-controls.test.tsx', 'tests/coalition-truth-bribe-targets.test.ts', 'tests/coalition-bribe-departure.test.tsx', 'tests/coalition-bribe-alliance-guides.test.ts', 'tests/coalition-outsider-commitment-funding.test.ts', 'tests/coalition-unlimited-worm-engine.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.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'] }, + { area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, admission with live bribe and alliance answers including matching joint commitments, four-member unlimited admission, funding and victory, staged larger-group faction helpers, funding with outsider-only commitments, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/coalition-bribe-admission.test.ts', 'tests/coalition-admission-deal-truth.test.ts', 'tests/coalition-admission-joint-commitments.test.ts', 'tests/coalition-admission-joint-stay.test.ts', 'tests/coalition-admission-joint-leave.test.ts', 'tests/coalition-pending-joint-leave.test.ts', 'tests/coalition-joint-leave-payment-order.test.ts', 'tests/coalition-whole-turn-leave-guard.test.ts', 'tests/coalition-alliance-admission-commitments.test.ts', 'tests/coalition-admission-bribe-controls.test.tsx', 'tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-truth-alliance.test.ts', 'tests/coalition-truth-alliance-controls.test.tsx', 'tests/coalition-truth-bribe-targets.test.ts', 'tests/coalition-bribe-departure.test.tsx', 'tests/coalition-bribe-alliance-guides.test.ts', 'tests/coalition-outsider-commitment-funding.test.ts', 'tests/coalition-unlimited-worm-engine.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.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/tests/coalition-admission-joint-commitments.test.ts b/tests/coalition-admission-joint-commitments.test.ts index 1053abde..35bc784d 100644 --- a/tests/coalition-admission-joint-commitments.test.ts +++ b/tests/coalition-admission-joint-commitments.test.ts @@ -43,24 +43,17 @@ function commitmentsLive(game: Game) { assert.equal(game.alliancePromises![0].released ?? false, false); } -void test('joint accepted-order promises cannot be released by group proposal or approval', () => { +void test('joint accepted-order promises remain binding after group admission', () => { let game = fixture(); - const before = structuredClone(game); - assert.throws(() => act(game, 'a', proposal(game))); - assert.deepEqual(game, before); + game = act(game, 'a', proposal(game)); commitmentsLive(game); - - // Another member may propose; the affected player can still decline, but - // cannot approve a group while the scalar reconciliation is unsupported. - game = act(game, 'b', proposal(game)); + game = act(game, 'b', answer(game, true)); commitmentsLive(game); game = act(game, 'c', answer(game, true)); commitmentsLive(game); - const pending = structuredClone(game); - assert.throws(() => act(game, 'a', answer(game, true))); - assert.deepEqual(game, pending); - commitmentsLive(game); - game = act(game, 'a', answer(game, false)); - commitmentsLive(game); assert.equal(game.coalitionState!.proposal, null); + assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','b','c']); + game = act(game, 'a', {type:'bribe', target:'e', amount:1}); + assert.equal(game.bribePromises![0].fulfilled, true); + assert.equal(game.alliancePromises![0].released ?? false, false); }); diff --git a/tests/coalition-admission-joint-leave.test.ts b/tests/coalition-admission-joint-leave.test.ts new file mode 100644 index 00000000..5180f868 --- /dev/null +++ b/tests/coalition-admission-joint-leave.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { baseDeck } from '../game/cards'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; + +const act = (game:Game, actor:string, action:Action) => applyAction(game,actor,action); +const seat = (game:Game, id:string) => game.players.find(player=>player.id===id)!; + +/** A quiet Basic Nexus, wallets and physical Truthtrances are staged; pair + * formation, questions, unanimous admission, departure and bribe are actions. */ +function fixture():{game:Game; cards:string[]} { + let game=createGame('GROUPADMITLEAVE',newPlayer('a','Atreides','atreides'),false); + game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'), + newPlayer('d','Harkonnen','harkonnen'),newPlayer('e','Guild','guild'), + newPlayer('f','Bene Gesserit','beneGesserit')); + Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true, + order:['a','b','c','d','e','f'],ready:[],deck:baseDeck()}); + game.coalitionState=emptyCoalitionState('unlimited'); + seat(game,'a').spice=8; + game=act(game,'a',{type:'alliance',target:'b'}); + game=act(game,'b',{type:'alliance',target:'a'}); + const cards:string[]=[]; + for (let count=0;count<2;count++) { + const index=game.deck.findIndex(card=>card.effect==='truthtrance'); assert.ok(index>=0); + const [card]=game.deck.splice(index,1); cards.push(card.id); seat(game,'f').hand.push(card); + } + return {game,cards}; +} +function ask(game:Game, card:string, question:Record, value:'yes'|'no'):Game { + game=act(game,'f',{type:'card',card}); + while (game.truthtrance?.stage==='priority') { + const next=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(next); + game=act(game,next.id,{type:'truthPass'}); + } + game=act(game,'f',{type:'truthAsk',question}); + return act(game,'a',{type:'truthAnswer',answer:value}); +} +function answer(game:Game, actor:string, accept:boolean):Game { + const offer=game.coalitionState!.proposal; assert.ok(offer); + return act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus, + turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision}); +} +function stillBinding(game:Game) { + assert.equal(game.alliancePromises!.find(row=>row.player==='a')!.released ?? false,false); + assert.equal(game.bribePromises!.find(row=>row.player==='a')!.released ?? false,false); +} + +for (const proposer of ['a','b']) void test(`a ${proposer === 'a' ? 'proposer' : 'final voter'} can admit, leave and pay without releasing their answers`,()=>{ + const {game:initial,cards}=fixture(); + let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'c'},'no'); + game=ask(game,cards[1],{kind:'bribe',target:'a',turn:game.turn,recipient:'e',minimum:2},'yes'); + // Empty joint ledger invokes the whole accepted-order planner. + game.turnActionPromises=[]; + const pair=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair); + game=act(game,proposer,{type:'coalitionPropose',groupId:pair.id,entrant:'c', + expectedRevision:game.coalitionState!.revision}); + if (proposer==='a') game=answer(game,'b',true); + game=answer(game,'c',true); + if (proposer==='b') game=answer(game,'a',true); + assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c']); + stillBinding(game); + const leave=viewGame(game,'a').alliancePromiseGuide; assert.ok(leave); + assert.equal(leave.action.type,'coalitionLeave'); + game=act(game,'a',leave.action); + assert.equal(coalitionOf(game.players,game.coalitionState,'a'),null); + assert.deepEqual(coalitionOf(game.players,game.coalitionState,'b')!.members,['b','c']); + stillBinding(game); + const payment=viewGame(game,'a').bribePromiseGuide; assert.ok(payment); + assert.equal(payment.action.type,'bribe'); assert.equal(payment.action.target,'e'); + game=act(game,'a',payment.action); + assert.equal(seat(game,'e').bribes,2); + assert.ok(game.bribePromises!.find(row=>row.player==='a')!.fulfilled); + stillBinding(game); + game=act(game,'a',{type:'ready'}); + assert.ok(game.ready.includes('a')); +}); diff --git a/tests/coalition-admission-joint-stay.test.ts b/tests/coalition-admission-joint-stay.test.ts new file mode 100644 index 00000000..32ecba36 --- /dev/null +++ b/tests/coalition-admission-joint-stay.test.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { baseDeck } from '../game/cards'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; + +const act = (game:Game, actor:string, action:Action) => applyAction(game,actor,action); +const seat = (game:Game, id:string) => game.players.find(player=>player.id===id)!; + +/** Stage a quiet Basic Nexus, six wallets and two physical Truthtrance cards. + * Pair formation, questions, admission answers and payments are real actions. */ +function fixture(): {game:Game; cards:string[]} { + let game = createGame('GROUPJOINTSTAY',newPlayer('a','Atreides','atreides'),false); + game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'), + newPlayer('d','Harkonnen','harkonnen'),newPlayer('e','Guild','guild'), + newPlayer('f','Bene Gesserit','beneGesserit')); + Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true, + order:['a','b','c','d','e','f'],ready:[],deck:baseDeck()}); + game.coalitionState = emptyCoalitionState('unlimited'); + seat(game,'a').spice = 8; + game = act(game,'a',{type:'alliance',target:'b'}); + game = act(game,'b',{type:'alliance',target:'a'}); + const cards:string[] = []; + for (let count=0;count<2;count++) { + const index = game.deck.findIndex(card=>card.effect==='truthtrance'); assert.ok(index>=0); + const [card] = game.deck.splice(index,1); cards.push(card.id); seat(game,'f').hand.push(card); + } + return {game,cards}; +} +function ask(game:Game, card:string, question:Record, value:'yes'|'no'):Game { + game = act(game,'f',{type:'card',card}); + while (game.truthtrance?.stage==='priority') { + const next=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(next); + game=act(game,next.id,{type:'truthPass'}); + } + game=act(game,'f',{type:'truthAsk',question}); + return act(game,'a',{type:'truthAnswer',answer:value}); +} +function propose(game:Game, actor:string):Game { + const pair = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair); + return act(game,actor,{type:'coalitionPropose',groupId:pair.id,entrant:'c', + expectedRevision:game.coalitionState!.revision}); +} +function answer(game:Game, actor:string, accept:boolean):Game { + const offer=game.coalitionState!.proposal; assert.ok(offer); + return act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus, + turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision}); +} +function live(game:Game) { + assert.ok(game.alliancePromises!.every(row=>!row.released)); + assert.ok(game.bribePromises!.every(row=>!row.released)); +} + +void test('a jointly feasible Yes to an existing ally and outsider bribe survives group admission and payment',()=>{ + const {cards,game:initial}=fixture(); + let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'b'},'yes'); + game=ask(game,cards[1],{kind:'bribe',target:'a',turn:game.turn,recipient:'e',minimum:2},'yes'); + // Even an empty ledger switches accepted-order reconciliation to the whole + // turn planner; both previously accepted answers must remain binding. + game.turnActionPromises=[]; + game=propose(game,'a'); + game=answer(game,'b',true); + game=answer(game,'c',true); + assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c']); + live(game); + const guide=viewGame(game,'a').bribePromiseGuide; assert.ok(guide); + assert.equal(guide.action.type,'bribe'); assert.equal(guide.action.target,'e'); + game=act(game,'a',guide.action); + assert.equal(seat(game,'e').bribes,2); + assert.ok(game.bribePromises!.find(row=>row.player==='a')!.fulfilled); + live(game); + game=act(game,'a',{type:'ready'}); + assert.ok(game.ready.includes('a')); + live(game); +}); + +void test('the respondent can decline admission that conflicts with simultaneous Yes and No',()=>{ + const {cards,game:initial}=fixture(); + let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'b'},'yes'); + game=ask(game,cards[1],{kind:'alliance',target:'a',turn:game.turn,partner:'c'},'no'); + // Stage an independently accepted ordinary bribe to exercise the joint + // reconciler without manufacturing a third physical Truthtrance card. + game.bribePromises=[{event:'joint-payment',player:'a',asker:'f',turn:game.turn, + recipient:'e',minimum:2,answer:true,progress:0}]; + game.turnActionPromises=[]; + game=propose(game,'b'); + game=answer(game,'c',true); + const before=structuredClone(game); + assert.throws(()=>answer(game,'a',true)); + assert.deepEqual(game,before); + live(game); + for (const difficulty of DIFFICULTIES) { + const view=viewGame(game,'a'); seatView(view,'a').bot=difficulty; + const action=botActions(view)[0]; assert.ok(action); + assert.equal(action.type,'coalitionAnswer'); assert.equal(action.accept,false); + assert.doesNotThrow(()=>act(game,'a',action)); + } + game=answer(game,'a',false); + assert.equal(game.coalitionState!.proposal,null); + live(game); +}); + +function seatView(view:ReturnType, id:string) { + const player=view.players.find(player=>player.id===id); assert.ok(player); return player; +} + +void test('admission checks a matching alliance, bribe and shipment against one physical purse',()=>{ + const {cards,game:initial}=fixture(); + let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'b'},'yes'); + game=ask(game,cards[1],{kind:'bribe',target:'a',turn:game.turn,recipient:'e',minimum:2},'yes'); + // Stage an already accepted shipment, its respondent's purse and a spent + // charity opportunity; the available physical reserves come from newPlayer. + game.shipmentPromises=[{player:'a',asker:'f',turn:game.turn,territory:'carthag',minimum:3,answer:true}]; + game.turnActionPromises=[]; + seat(game,'a').charityTurn=game.turn; + seat(game,'a').spice=4; // Two spice for the bribe plus three to ship to Carthag. + const before=structuredClone(game); + assert.throws(()=>propose(game,'a')); + assert.deepEqual(game,before); + live(game); + assert.ok(!game.shipmentPromises![0].released); + + seat(game,'a').spice=5; + game=propose(game,'a'); + game=answer(game,'b',true); + game=answer(game,'c',true); + live(game); + assert.ok(!game.shipmentPromises![0].released); + const guide=viewGame(game,'a').bribePromiseGuide; assert.ok(guide); + game=act(game,'a',guide.action); + assert.equal(seat(game,'a').spice,3); + assert.equal(seat(game,'e').bribes,2); + assert.ok(game.bribePromises![0].fulfilled); + assert.ok(!game.shipmentPromises![0].released); +}); diff --git a/tests/coalition-joint-leave-payment-order.test.ts b/tests/coalition-joint-leave-payment-order.test.ts new file mode 100644 index 00000000..4c51b728 --- /dev/null +++ b/tests/coalition-joint-leave-payment-order.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { baseDeck } from '../game/cards'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; + +const act=(game:Game,actor:string,action:Action)=>applyAction(game,actor,action); +const seat=(game:Game,id:string)=>game.players.find(player=>player.id===id)!; + +/** Only a quiet Basic Nexus, wallets, an exhausted charity opportunity and + * two physical Truthtrances are staged. A later purse reduction represents a + * foreign loss. All group and Truthtrance actions and both bribes are real. */ +function fixture():{game:Game;cards:string[]} { + let game=createGame('GROUPPAYORDER',newPlayer('a','Atreides','atreides'),false); + game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'), + newPlayer('d','Harkonnen','harkonnen'),newPlayer('e','Guild','guild'), + newPlayer('f','Bene Gesserit','beneGesserit')); + Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true, + order:['a','b','c','d','e','f'],ready:[],deck:baseDeck()}); + game.coalitionState=emptyCoalitionState('unlimited'); + seat(game,'a').spice=3; seat(game,'a').charityTurn=game.turn; + seat(game,'d').spice=8; + game=act(game,'a',{type:'alliance',target:'b'}); + game=act(game,'b',{type:'alliance',target:'a'}); + const cards:string[]=[]; + for (let count=0;count<2;count++) { + const index=game.deck.findIndex(card=>card.effect==='truthtrance'); assert.ok(index>=0); + const [card]=game.deck.splice(index,1); cards.push(card.id); seat(game,'f').hand.push(card); + } + return {game,cards}; +} +function ask(game:Game,card:string,question:Record,value:'yes'|'no'):Game { + game=act(game,'f',{type:'card',card}); + while (game.truthtrance?.stage==='priority') { + const next=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(next); + game=act(game,next.id,{type:'truthPass'}); + } + game=act(game,'f',{type:'truthAsk',question}); + return act(game,'a',{type:'truthAnswer',answer:value}); +} +function answer(game:Game,actor:string):Game { + const offer=game.coalitionState!.proposal; assert.ok(offer); + return act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus, + turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision}); +} +function stillBinding(game:Game) { + assert.equal(game.alliancePromises!.find(row=>row.player==='a')!.released ?? false,false); + assert.equal(game.bribeExpressionPromises!.find(row=>row.player==='a')!.released ?? false,false); +} + +void test('owned departure can precede payment to the former ally when it is the only affordable OR branch',()=>{ + const {game:initial,cards}=fixture(); + let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'d'},'no'); + game=ask(game,cards[1],{kind:'bribeExpression',target:'a',turn:game.turn,claim:{kind:'or',terms:[ + {kind:'payment',recipient:'d',minimum:1},{kind:'payment',recipient:'e',minimum:3}]}},'yes'); + // An empty ledger activates joint reconciliation. The three-spice outsider + // branch is available when A consents to the group including D. + game.turnActionPromises=[]; + const pair=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair); + game=act(game,'a',{type:'coalitionPropose',groupId:pair.id,entrant:'d', + expectedRevision:game.coalitionState!.revision}); + game=answer(game,'b'); game=answer(game,'d'); + assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','d']); + stillBinding(game); + // Stage a loss after admission. E's branch now costs too much, but A can + // satisfy both answers by leaving and paying the former ally D one spice. + seat(game,'a').spice=1; + game=act(game,'d',{type:'bribe',target:'e',amount:1}); + stillBinding(game); + const leave=viewGame(game,'a').alliancePromiseGuide; assert.ok(leave); + assert.equal(leave.action.type,'coalitionLeave'); + game=act(game,'a',leave.action); + assert.equal(coalitionOf(game.players,game.coalitionState,'a'),null); + assert.deepEqual(coalitionOf(game.players,game.coalitionState,'b')!.members,['b','d']); + stillBinding(game); + const payment=viewGame(game,'a').bribePromiseGuide; assert.ok(payment); + assert.equal(payment.action.type,'bribe'); assert.equal(payment.action.target,'d'); + game=act(game,'a',payment.action); + assert.equal(seat(game,'d').bribes,1); + assert.ok(game.bribeExpressionPromises!.find(row=>row.player==='a')!.fulfilled); + stillBinding(game); +}); diff --git a/tests/coalition-pending-joint-leave.test.ts b/tests/coalition-pending-joint-leave.test.ts new file mode 100644 index 00000000..53a560de --- /dev/null +++ b/tests/coalition-pending-joint-leave.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { baseDeck } from '../game/cards'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; + +const act = (game:Game, actor:string, action:Action) => applyAction(game,actor,action); +const seat = (game:Game, id:string) => game.players.find(player=>player.id===id)!; + +/** The quiet Basic Nexus, wallets and physical Truthtrances are staged. Group + * formation, both answers, the invitation, departure and bribes are actions. */ +function fixture():{game:Game; cards:string[]} { + let game=createGame('GROUPPENDINGLEAVE',newPlayer('a','Atreides','atreides'),false); + game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'), + newPlayer('d','Harkonnen','harkonnen'),newPlayer('e','Guild','guild'), + newPlayer('f','Bene Gesserit','beneGesserit')); + Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true, + order:['a','b','c','d','e','f'],ready:[],deck:baseDeck()}); + game.coalitionState=emptyCoalitionState('unlimited'); + for (const player of game.players) player.spice=8; + game=act(game,'a',{type:'alliance',target:'b'}); + game=act(game,'b',{type:'alliance',target:'a'}); + const pair=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair); + game=act(game,'a',{type:'coalitionPropose',groupId:pair.id,entrant:'c', + expectedRevision:game.coalitionState!.revision}); + game=answer(game,'b',true); game=answer(game,'c',true); + const cards:string[]=[]; + for (let count=0;count<2;count++) { + const index=game.deck.findIndex(card=>card.effect==='truthtrance'); assert.ok(index>=0); + const [card]=game.deck.splice(index,1); cards.push(card.id); seat(game,'f').hand.push(card); + } + return {game,cards}; +} +function ask(game:Game, card:string, question:Record, value:'yes'|'no'):Game { + game=act(game,'f',{type:'card',card}); + while (game.truthtrance?.stage==='priority') { + const next=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(next); + game=act(game,next.id,{type:'truthPass'}); + } + game=act(game,'f',{type:'truthAsk',question}); + return act(game,'a',{type:'truthAnswer',answer:value}); +} +function answer(game:Game, actor:string, accept:boolean):Game { + const offer=game.coalitionState!.proposal; assert.ok(offer); + return act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus, + turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision}); +} +function stillBinding(game:Game) { + assert.equal(game.alliancePromises!.find(row=>row.player==='a')!.released ?? false,false); + assert.equal(game.bribePromises!.find(row=>row.player==='a')!.released ?? false,false); +} + +void test('a pending group invitation preserves an owned leave and joint bribe through unrelated actions',()=>{ + const {game:initial,cards}=fixture(); + let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'b'},'no'); + game=ask(game,cards[1],{kind:'bribe',target:'a',turn:game.turn,recipient:'e',minimum:2},'yes'); + // An empty ledger invokes the joint accepted-order reconciler. + game.turnActionPromises=[]; + assert.equal(viewGame(game,'a').alliancePromiseGuide?.action.type,'coalitionLeave'); + const group=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group); + game=act(game,'c',{type:'coalitionPropose',groupId:group.id,entrant:'d', + expectedRevision:game.coalitionState!.revision}); + assert.ok(game.coalitionState!.proposal); + stillBinding(game); + game=answer(game,'d',true); + stillBinding(game); + game=answer(game,'a',false); + assert.equal(game.coalitionState!.proposal,null); + stillBinding(game); + game=act(game,'d',{type:'bribe',target:'f',amount:1}); + stillBinding(game); + const leave=viewGame(game,'a').alliancePromiseGuide; assert.ok(leave); + assert.equal(leave.action.type,'coalitionLeave'); + game=act(game,'a',leave.action); + assert.equal(coalitionOf(game.players,game.coalitionState,'a'),null); + assert.deepEqual(coalitionOf(game.players,game.coalitionState,'b')!.members,['b','c']); + stillBinding(game); + const payment=viewGame(game,'a').bribePromiseGuide; assert.ok(payment); + assert.equal(payment.action.type,'bribe'); assert.equal(payment.action.target,'e'); + game=act(game,'a',payment.action); + assert.equal(seat(game,'e').bribes,2); + assert.ok(game.bribePromises!.find(row=>row.player==='a')!.fulfilled); + assert.equal(game.alliancePromises!.find(row=>row.player==='a')!.released ?? false,false); +}); diff --git a/tests/coalition-whole-turn-leave-guard.test.ts b/tests/coalition-whole-turn-leave-guard.test.ts new file mode 100644 index 00000000..fefb5adf --- /dev/null +++ b/tests/coalition-whole-turn-leave-guard.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { baseDeck } from '../game/cards'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; + +const act = (game:Game, actor:string, action:Action) => applyAction(game,actor,action); + +/** The quiet Nexus, wallet, Truthtrance and the later conflicting scalar + * answer are staged. Formation, the whole question and payment are real. */ +function fixture(): Game { + let game = createGame('GROUPWHOLELEAVE',newPlayer('a','Atreides','atreides'),false); + game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'), + newPlayer('d','Harkonnen','harkonnen'),newPlayer('e','Guild','guild')); + Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true, + order:['a','b','c','d','e'],ready:[],deck:baseDeck()}); + game.coalitionState=emptyCoalitionState('unlimited'); + game.players.find(player=>player.id==='a')!.spice=8; + game=act(game,'a',{type:'alliance',target:'b'}); + game=act(game,'b',{type:'alliance',target:'a'}); + const pair=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair); + game=act(game,'a',{type:'coalitionPropose',groupId:pair.id,entrant:'c', + expectedRevision:game.coalitionState!.revision}); + for (const actor of ['b','c']) { + const offer=game.coalitionState!.proposal!; + game=act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus, + turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision}); + } + const index=game.deck.findIndex(card=>card.effect==='truthtrance'); assert.ok(index>=0); + const [card]=game.deck.splice(index,1); + game.players.find(player=>player.id==='e')!.hand.push(card); + game=act(game,'e',{type:'card',card:card.id}); + while (game.truthtrance?.stage==='priority') { + const actor=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(actor); + game=act(game,actor.id,{type:'truthPass'}); + } + game=act(game,'e',{type:'truthAsk',question:{kind:'turnActions',target:'a',turn:game.turn, + mixed:{kind:'and',terms:[{kind:'action',action:{kind:'payment',recipient:'d',minimum:2}}, + {kind:'action',action:{kind:'ship',territory:'polar_sink',minimum:1}}]}}}); + game=act(game,'a',{type:'truthAnswer',answer:'yes'}); + game=act(game,'a',{type:'bribe',target:'d',amount:2}); + assert.ok(game.turnActionPromises?.some(row=>!row.fulfilled && !row.released)); + const source=JSON.stringify(['ordinaryNexus',game.turn,game.spiceDiscard.map(pile=>pile.length), + game.spiceSequence?.pile ?? null,game.spiceSequence?.skipped?.length ?? 0]); + game.alliancePromises=[{event:'staged-no',source,player:'a',asker:'e',turn:game.turn, + partner:'b',answer:false}]; + return game; +} + +void test('a live whole-turn promise does not expose a forbidden group-leave guide',()=>{ + const game=fixture(); + const guide=viewGame(game,'a').turnActionGuide; + assert.notEqual(guide?.action.type,'coalitionLeave'); + if (guide) assert.doesNotThrow(()=>act(game,'a',guide.action)); +}); diff --git a/tests/truth-turn-alliance-runtime.test.ts b/tests/truth-turn-alliance-runtime.test.ts index af2b331d..51712d36 100644 --- a/tests/truth-turn-alliance-runtime.test.ts +++ b/tests/truth-turn-alliance-runtime.test.ts @@ -147,7 +147,7 @@ void test('native missing reciprocal consent never permits a definite combined f }); void test('native known unaffordable payment with existing real consent offers No rather than treating finance as unknown consent', () => { - const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient, 11)); + const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient, 13)); const asked = ask(declare(f).game, f.asker, f.target, mixed); assert.equal(asked.allianceOffers[f.partner], f.target); assert.equal(player(asked, f.target).spice, 10); assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); @@ -156,7 +156,7 @@ void test('native known unaffordable payment with existing real consent offers N }); void test('native missing consent cannot conceal a known unaffordable whole payment branch as Unknown', () => { - const f = fixture({ consent: 'none' }), mixed = group('and', partner(f.partner), payment(f.recipient, 11)); + const f = fixture({ consent: 'none' }), mixed = group('and', partner(f.partner), payment(f.recipient, 13)); const game = ask(declare(f).game, f.asker, f.target, mixed); assert.equal(player(game, f.target).spice, 10); assert.deepEqual(viewGame(game, f.target).truthTurnActionAnswers, ['no']); @@ -166,6 +166,24 @@ void test('native missing consent cannot conceal a known unaffordable whole paym assert.deepEqual(inventory(game), f.originalCards); }); +void test('native final-partner answer can fund eleven through a real payment and later Charity', () => { + const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient, 11)); + const asked = ask(declare(f).game, f.asker, f.target, mixed); + assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('yes')); + let game = answer(asked, f.target, 'yes'); + game = act(game, f.target, { type:'bribe', target:f.recipient, amount:10 }); + assert.equal(player(game, f.target).spice, 0); + game = act(game, f.target, f.allianceAction); + game = close(game); + game = advance(game, state => state.phase === 2 && clean(state)); + game = act(game, f.target, { type:'charity' }); + assert.equal(player(game, f.target).spice, 2); + game = act(game, f.target, { type:'bribe', target:f.recipient, amount:1 }); + assert.equal(player(game, f.recipient).bribes, 11); + assert.equal(promise(game, f.target).fulfilled, true); + physical(game, f); +}); + void test('native payment after own Nexus Ready uniformly reopens readiness and preserves a required later break', () => { const f = fixture({ consent: 'allied' }), mixed = group('and', partner(f.partner), payment(f.recipient)); let game = bind(f, 'no', mixed).game;