From 44e7cf800189d64829f7f47cbab9dfd73246385e Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:50:33 +1100 Subject: [PATCH] Recognize group Guild shipment promises --- docs/CURRENT_STATUS.md | 15 ++- docs/OPTIONAL_ALLIANCE_VICTORY.md | 19 +++ game/engine.ts | 2 +- ...lition-fremen-guild-truth-shipment.test.ts | 116 ++++++++++++++++++ 4 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 tests/coalition-fremen-guild-truth-shipment.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 8c4a6e4f..c01672fa 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,15 @@ open mode gates. ## Current checkpoint and work +**Group Fremen–Guild shipment promise — 29 October. Prototyped / Partial.** +In a staged Basic group, the Truthtrance shipment search now recognizes a legal +Fremen reserve shipment through its Guild ally. A focused journey forms the +group through Nexus, stages Movement, then completes the real question and +paid shipment; all four bot profiles have a legal path. Twenty-five related +cases, typecheck, lint and the production build pass. Group lobby admission +and full rules acceptance remain open. +[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-fremenguild-shipment-answer--29-october-2026). + **Truthtrance card types — 29 October. Prototyped / Partial.** Verified current-hand questions can distinguish primary projectile and poison weapons and primary projectile and poison defenses. Physical hybrid cards count for @@ -25,12 +34,6 @@ focused cases pass, including human and bot answers; typecheck, lint and the production build pass. Full Truthtrance and mode acceptance remain open. [Scope](TRUTHTRANCE_HAND_INVENTORY.md#primary-attack-and-defense-types--29-october-2026). -**Group-alliance lobby remains unexposed.** A source inspection found that -the Basic shipment-promise search checks the scalar Fremen–Guild alliance -even though a three-member coalition clears that scalar. The native transport -permission has a group-aware path. This is a candidate legal-action gap to -reproduce and resolve before exposing group play; no group lobby was added. - **Whole-board Truthtrance forces — 29 October. Prototyped / Partial.** Players can ask a verified physical-force question across the current Arrakis board. The room's current locations include a placed Mobile Stronghold and diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md index 210cec5f..b233d2b0 100644 --- a/docs/OPTIONAL_ALLIANCE_VICTORY.md +++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md @@ -1,5 +1,24 @@ # Optional three-stronghold alliance victory +## Group Fremen–Guild shipment answer — 29 October 2026 + +**Prototyped / Partial.** In the staged Basic group, a Fremen member can +answer Yes to a Truthtrance reserve-shipment question when its actual Guild +ally can transport the group to the named territory. The completion search +uses the same group-aware native Guild permission as the transport itself, +including its cancellation state, rather than relying on the pair-only ally +field cleared by a larger coalition. The printed transport, two native Karama +response windows, actual cost and physical force transfer remain unchanged. + +The [focused journey](../tests/coalition-fremen-guild-truth-shipment.test.ts) +forms a group through actual Nexus actions after staging an empty ledger; it +then stages the Movement phase, conserved force pools and wallets and transfers +the actual Truthtrance and Karama from the undealt deck. It verifies the legal +Guild route to Carthag, a real question and fulfillment, and minimal legal +responses across four bot profiles. Group consent and timing are provisional +online interpretations; this does not expose a lobby option or certify full +group play, Advanced combinations or other shipment continuations. + ## Self-funded Guild transport with a live promise — 29 October 2026 In staged Basic and Advanced group games, a Guild ally's transport quote now diff --git a/game/engine.ts b/game/engine.ts index 22708662..47e8043e 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -32347,7 +32347,7 @@ function findShipmentCompletion( if (!(error instanceof RuleError)) throw error; } } - if (p.faction === 'fremen' && byFaction(g, 'guild')?.id === p.ally) { + if (p.faction === 'fremen' && nativeGuildTransportPermission(g, p)) { const action: Action = { type: 'guildShip', from: 'reserves', diff --git a/tests/coalition-fremen-guild-truth-shipment.test.ts b/tests/coalition-fremen-guild-truth-shipment.test.ts new file mode 100644 index 00000000..9561c4e1 --- /dev/null +++ b/tests/coalition-fremen-guild-truth-shipment.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { baseDeck } from '../game/cards'; +import { botActions } from '../game/bots'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; + +const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); +const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!; +const shipment: Action = {type:'guildShip', from:'reserves', territory:'carthag', sector:11, amount:3, elite:0, allyPayment:0}; + +/** The unexposed group mode requires a staged empty ledger and later Movement + * phase; the pair and third member join using actual Nexus actions. Forces and + * wallets are staged with physical totals intact, and the actual Truthtrance + * card is transferred from the undealt deck. No saved room is touched. */ +function fixture() { + let game = createGame('GROUPFREMENSHIP', newPlayer('g', 'Guild', 'guild'), false); + game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'), + newPlayer('h', 'Harkonnen', 'harkonnen')); + Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['g','e','f','h'], + ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null}); + game.coalitionState = emptyCoalitionState(); + game = act(game, 'g', {type:'alliance', target:'e'}); + game = act(game, 'e', {type:'alliance', target:'g'}); + const pair = coalitionOf(game.players, game.coalitionState, 'g'); assert.ok(pair); + game = act(game, 'g', {type:'coalitionPropose', groupId:pair.id, entrant:'f', + expectedRevision:game.coalitionState!.revision}); + for (const id of ['e','f']) { + const proposal = game.coalitionState!.proposal!; + game = act(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus, + turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision}); + } + assert.equal(player(game,'f').ally, null, 'A three-member group does not use the pair-only scalar.'); + Object.assign(game, {phase:5, nexus:false, active:'f', ready:[], + movementRemaining:['f','g','e','h'], guildTimingGranted:false, guildTimingLocked:false}); + for (const seat of game.players) { + seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10; + seat.hand = []; seat.traitors = []; seat.traitorChoices = []; seat.shipped = false; seat.moved = 0; + } + const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0); + const [truth] = game.deck.splice(index,1); player(game,'h').hand.push(truth); + // A distinct undealt Karama gives the outside player a real opportunity to + // counter the native permission and rate; it stays held when passed. + const karama = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(karama >= 0); + player(game,'h').hand.push(...game.deck.splice(karama,1)); + return {game, truth}; +} +function settle(game: Game): Game { + for (let n = 0; game.response && n < 32; n++) { + const seat = game.players.find(candidate => !game.response!.passed.includes(candidate.id)); assert.ok(seat); + game = act(game,seat.id,{type:'passResponse'}); + } + assert.equal(game.response ?? null,null); + return game; +} +function ask() { + const {game:before, truth} = fixture(); + let game = act(before,'h',{type:'card',card:truth.id}); + while (game.truthtrance?.stage === 'priority') { + const seat = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(seat); + game = act(game,seat.id,{type:'truthPass'}); + } + game = act(game,'h',{type:'truthAsk',question:{kind:'shipment',target:'f',turn:game.turn, + territory:'carthag',minimum:3}}); + assert.equal(game.truthtrance?.stage,'answer'); + return {before,game,truth}; +} + +void test('group-allied Fremen can actually ship from reserves to Carthag with native Guild permission', () => { + let {game} = fixture(); + // Carthag is outside the Fremen southern reinforcement area. The native + // group-aware Guild path is the real legal transport for this location. + assert.throws(() => act(game,'f',{type:'ship',territory:'carthag',sector:11,amount:3,elite:0})); + game = act(game,'f',shipment); + assert.equal(game.response?.kind,'guildTransportPermission'); + assert.equal(player(game,'f').reserves,20); + game = settle(game); + assert.equal(player(game,'f').forces['carthag:11'],3); + assert.equal(player(game,'f').reserves,17); + assert.equal(player(game,'f').spice,8); + assert.equal(player(game,'g').spice,12); +}); + +void test('a group-allied Fremen can answer Yes to the real Carthag reserve-shipment question and fulfill it', () => { + const {game:asked,truth} = ask(); + assert.ok(viewGame(asked,'f').truthShipmentAnswers?.includes('yes')); + let game = act(asked,'f',{type:'truthAnswer',answer:'yes'}); + assert.ok(game.shipmentPromises?.some(promise => promise.player === 'f' && promise.answer && !promise.released)); + assert.equal(game.discard.filter(card => card.id === truth.id).length,1); + assert.throws(() => act(game,'f',{type:'endMovement'}),/Truthtrance/i); + game = act(game,'f',shipment); + assert.equal(game.response?.kind,'guildTransportPermission'); + game = settle(game); + assert.equal(player(game,'f').forces['carthag:11'],3); + assert.equal(player(game,'f').spice,8); + assert.equal(player(game,'g').spice,12); + assert.ok(game.shipmentPromises?.some(promise => promise.player === 'f' && promise.fulfilled && !promise.released)); +}); + +void test('all four bot profiles can answer and follow the original group Guild shipment witness', () => { + const {game:asked} = ask(); + for (const difficulty of ['Easy','Medium','Hard','Brutal'] as const) { + const answerView = viewGame(asked,'f'); + answerView.players.find(seat => seat.id === 'f')!.bot = difficulty; + const answer = botActions(answerView).find(action => action.type === 'truthAnswer'); assert.ok(answer); + assert.equal(answer.answer,'yes'); + let game = act(structuredClone(asked),'f',answer); + const movementView = viewGame(game,'f'); + movementView.players.find(seat => seat.id === 'f')!.bot = difficulty; + const next = botActions(movementView)[0]; assert.equal(next?.type,'guildShip'); + assert.equal(next.from,'reserves'); assert.equal(next.territory,'carthag'); + game = act(game,'f',next); + game = settle(game); + assert.ok(game.shipmentPromises?.some(promise => promise.player === 'f' && promise.fulfilled && !promise.released)); + } +});