diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index dacf3419..1a850168 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,43 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Discovery blow / No-Field — 27 October. Prototyped / Partial.** +An original Discovery blow now reveals an affected concealed marker through a +distinct `discoveryBlow` cause, materializes only available reserves and sends +the whole actual target group to Tanks.0 creates no counter. Original old-spice +removal, fresh six subject to storm and separate face-down token placement +continue. Dedicated preflight imports no Fremen/ally worm immunity. Separate +territory IDs remain outside the parent target. Forced reveal and nested-target +composition are explicitly provisional; no specific combined publisher ruling +is claimed. Existing Ready/Accept controls and minimal policies continue normally. +[Source and contract](DISCOVERY_PROTOTYPE.md#concealed-no-field-at-a-discovery-blow--27-october-2026). + +Frozen **9-file union, 70/70**, types/lint/build and eight original-game +programmes pass. Public Basic/Advanced R/A/E+CHOAM-deck starts actually opt in +to Discoveries before readiness/start. Marker0/3/5 costs2 at WindPassNorth17 +before the real turn2 Discovery; each mode's mixed case first ships physical1 +on turn1, then marker5 on turn2 before the turn3 blow. Actual Emperor Polar1 +survives. Native token placement remains at separate PlasticBasin13. + +Runtime records24 GameTable SSR and8 actual Ready callbacks, with source/further +action arrays488/44. Other Ready/Accept and source controls use native packets. +There is no browser hydration/navigation or clicked-control claim for them. +No played hand, purse, army, phase, marker, event, spice, token placement or +casualty is installed. Controlled helper cases separately cover scarce/empty +reserves, elite validity, numeric capacity, Fremen and separate nested markers; +no depleted-supply or nested-source native journey is claimed. + +The first union exposed an older genuine Homeworld Discovery driver's wrong +Charity actor; it now follows the actual ordered turn. Type feedback repaired +two test assertions without changing gameplay. Failed v1/v2 proof is preserved; +v3 reruns the frozen related source and runtime after repair. +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-discovery-no-field-blow-proof-v3/`; +journeys/HTML: adjacent `20261027-discovery-no-field-blow-runtime/run-v2/`. +Only a recorded three-document evidence overlay follows verification. Existing +server HTTP200, no restart or saved-game/database operation. Broader combinations, +complete modes and final rules/recovery/privacy/custody/browser/deployed +acceptance remain open. + **Concealed No-Field Guild transport — 27 October. Prototyped / Partial.** Native reciprocal Guild allies can cross-ship their owned concealed Richese marker alone or with same-origin counters. Original permission/rate responses, diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md index 55e14a8d..50fe63ca 100644 --- a/docs/DISCOVERY_PROTOTYPE.md +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -5,6 +5,54 @@ partial rules coverage. It connects physical components, real actions, private controls, AI choices and saved continuation. Normal public Discovery and Advanced start gates, and the publication gate, remain closed. +## Concealed No-Field at a Discovery blow — 27 October 2026 + +Publisher E3 physical/printed12 and supplied Advanced physical23/printed21 +destroy existing spice and forces in the Discovery card's printed territory. +The Fremen exception is part of Great Maker, not this effect. E2 FAQ10 and +supplied physical36/printed34 B count the concealed No-Field as one for all +effects, including0. The latter's mandatory revelation list does not explicitly +include Discovery. A targeted publisher/designer search found no combined ruling. + +Under the standing recommended interpretation, reveal through the distinct +`discoveryBlow` cause, materialize no more than actual reserves, then destroy +all actual counters in the target.0 creates no invented casualty. This forced +reveal is provisional. Dedicated preflight validates every affected player; +Fremen, its ally and ordinary worm protection are not exempt. Original spice +removal, fresh six subject to storm, physical token type/supply and separate +placement destination continue once. Nested locations keep their separate +territory IDs (E3p13); this is the selected territorial composition, not a new +published immunity. No mode or module gate changes. + +Existing Ready/Accept controls and minimal policies advance the normal phase. +The chronicle names the provisional destructive reveal and actual counters; +no extra confirmation or new player action is required. The pure model keeps +token history and reserve caps; the engine owns actual loss and placement. +[No-Field contract](RICHESE_NO_FIELD_RULES.md#discovery-spice-blow-destruction--27-october-2026). + +### Scoped native evidence + +Frozen9-file union70/70, types/lint/build and eight original programmes pass. +Public Basic/Advanced R/A/E+CHOAM starts opt Discoveries in before Ready/start. +Actual paid marker0/3/5 shipments precede the original later WindPassNorth17 +Discovery. Mixed cases earn physical1 on turn1, then marker5 on turn2 before +the turn3 blow; all six actual counters die once. Original Emperor Polar1 +survives. Original fresh6 and native face-down placement at separate +PlasticBasin13 follow; Spice Blow closes into Charity without another reveal. + +Runtime records24 GameTable SSR and8 actual Ready callbacks; source/further +arrays488/44. Other controls use native packets directly. No played hand, +purse, force, phase, marker/event, spice, placement or casualty is installed. +Scarce reserves/typed capacity and nested markers are controlled supplements, +not native depleted/nested journeys. An old genuine Homeworld Discovery driver +now obeys current Charity order; two test assertions were repaired after type +feedback. Failed v1/v2 remains preserved; v3 uses one frozen source and then a +three-document evidence overlay. Existing server HTTP200 without restart or +saved-game/database operation. Browser hydration/navigation, deployed and final +full-mode/combined/recovery/privacy/custody acceptance remain open. +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-discovery-no-field-blow-proof-v3/`; +runtime: adjacent `20261027-discovery-no-field-blow-runtime/run-v2/`. + ## Recommended physical dial and cumulative storm choices — 14 October 2026 Under the user's choose-recommended-option priority, the prototype now selects diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 5d692dd3..a7703aac 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,44 @@ # Dune implementation status +## 27 October 2026 — Discovery blow with concealed No-Field + +**Discovery blow / No-Field — 27 October. Prototyped / Partial.** +An original Discovery blow now reveals an affected concealed marker through a +distinct `discoveryBlow` cause, materializes only available reserves and sends +the whole actual target group to Tanks.0 creates no counter. Original old-spice +removal, fresh six subject to storm and separate face-down token placement +continue. Dedicated preflight imports no Fremen/ally worm immunity. Separate +territory IDs remain outside the parent target. Forced reveal and nested-target +composition are explicitly provisional; no specific combined publisher ruling +is claimed. Existing Ready/Accept controls and minimal policies continue normally. +[Source and contract](DISCOVERY_PROTOTYPE.md#concealed-no-field-at-a-discovery-blow--27-october-2026). + +Frozen **9-file union, 70/70**, types/lint/build and eight original-game +programmes pass. Public Basic/Advanced R/A/E+CHOAM-deck starts actually opt in +to Discoveries before readiness/start. Marker0/3/5 costs2 at WindPassNorth17 +before the real turn2 Discovery; each mode's mixed case first ships physical1 +on turn1, then marker5 on turn2 before the turn3 blow. Actual Emperor Polar1 +survives. Native token placement remains at separate PlasticBasin13. + +Runtime records24 GameTable SSR and8 actual Ready callbacks, with source/further +action arrays488/44. Other Ready/Accept and source controls use native packets. +There is no browser hydration/navigation or clicked-control claim for them. +No played hand, purse, army, phase, marker, event, spice, token placement or +casualty is installed. Controlled helper cases separately cover scarce/empty +reserves, elite validity, numeric capacity, Fremen and separate nested markers; +no depleted-supply or nested-source native journey is claimed. + +The first union exposed an older genuine Homeworld Discovery driver's wrong +Charity actor; it now follows the actual ordered turn. Type feedback repaired +two test assertions without changing gameplay. Failed v1/v2 proof is preserved; +v3 reruns the frozen related source and runtime after repair. +Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-discovery-no-field-blow-proof-v3/`; +journeys/HTML: adjacent `20261027-discovery-no-field-blow-runtime/run-v2/`. +Only a recorded three-document evidence overlay follows verification. Existing +server HTTP200, no restart or saved-game/database operation. Broader combinations, +complete modes and final rules/recovery/privacy/custody/browser/deployed +acceptance remain open. + ## 27 October 2026 — Concealed No-Field Guild transport **Concealed No-Field Guild transport — 27 October. Prototyped / Partial.** diff --git a/docs/RICHESE_NO_FIELD_RULES.md b/docs/RICHESE_NO_FIELD_RULES.md index 39df9629..2f911949 100644 --- a/docs/RICHESE_NO_FIELD_RULES.md +++ b/docs/RICHESE_NO_FIELD_RULES.md @@ -2,6 +2,26 @@ Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode. +## Discovery spice-blow destruction — 27 October 2026 + +Publisher E3 physical/printed12 and supplied Advanced physical23/printed21 +destroy existing spice and all forces in the printed Discovery-blow territory. +Compose the one-force rule (E2 FAQ10/supplied physical36 B) using a distinct +`discoveryBlow` cause: reveal, materialize only actual available reserves, then +send the whole affected physical group to Tanks.0 adds no counter. The native +sequence continues to fresh spice and original face-down token placement. + +Discovery is absent from the explicit forced-reveal list. Targeted GF9/designer +search found no dedicated combined answer, so destructive revelation is an +explicit provisional interpretation under standing recommended-option authority. +Do not import ordinary worm protection for Fremen or its ally. Other territories, +including separately identified revealed nested locations, are outside the +printed parent target; this is territorial composition, not an additional +publisher Discovery-immunity ruling. Original storm exclusion for the fresh +spice and supply-type placement remain. Existing phase Ready/Accept controls +continue without an extra confirmation; history names the provisional reveal. +[Discovery source and scope](DISCOVERY_PROTOTYPE.md#concealed-no-field-at-a-discovery-blow--27-october-2026). + ## Concealed Guild cross-shipment and Secret Ally return — 27 October 2026 The supplied Advanced source, physical36/printed34 B, and E2 FAQ printed10 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 8ce4a078..33c7034c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,19 @@ # Rule decision index +## Discovery blow and concealed No-Field — 27 October 2026 + +Publisher E3physical/printed12 and supplied Advanced physical23/printed21 +destroy old spice and all forces in the printed target. E2FAQ10/supplied +physical36 B's marker-as-one rule composes provisionally through the distinct +`discoveryBlow` reveal: cap materialization to actual reserves, then destroy +the actual whole group.0 invents no casualty. No Fremen/ally worm immunity is +borrowed. Original new-spice/storm/token placement follows. Separate nested +territory IDs stay outside the parent target; this is territorial composition, +not a new published immunity. Targeted GF9/designer search found no specific +combined ruling, and Discovery is not in the printed mandatory-reveal list. +Standing recommended-option authority selects this explicit provisional path. +[Contract](DISCOVERY_PROTOTYPE.md#concealed-no-field-at-a-discovery-blow--27-october-2026). + ## Concealed No-Field Guild transport — 27 October 2026 Under the standing recommended-option policy, compose supplied Advanced diff --git a/game/disaster-preflight.ts b/game/disaster-preflight.ts index c25d9b1c..a1eacb8f 100644 --- a/game/disaster-preflight.ts +++ b/game/disaster-preflight.ts @@ -3,6 +3,7 @@ import type { Game, Player, ResponseWindow } from './engine'; import { splitLocation, territory, validLocation } from './board'; import { SPICE_CARDS } from './cards'; import { revealRicheseNoField } from './richese-no-field'; +import { DISCOVERY_SPICE_CARDS } from './discoveries'; export class DisasterPreflightError extends Error {} function requireDisaster( @@ -31,7 +32,7 @@ function casualtyCustody( g: Game, p: Player, affected: (key: string) => boolean, - cause: 'storm' | 'worm', + cause: 'storm' | 'worm' | 'discoveryBlow', ) { let losses = 0, eliteLosses = 0; @@ -173,6 +174,21 @@ export function validateStormCancellation(g: Game, response: ResponseWindow) { ); casualtyCustody(g, p, (candidate) => candidate === key, 'storm'); } +/** A printed Discovery blow destroys the entire named territory. Native worm + * survival and allied protection do not exempt any faction from this effect. */ +export function validateDiscoveryBlow(g: Game, t: string) { + requireDisaster( + DISCOVERY_SPICE_CARDS.some(card => card.territory === t), + 'The Discovery spice blow needs a printed Discovery territory.', + ); + for (const p of g.players) + casualtyCustody( + g, + p, + (key) => splitLocation(key).territory === t && !!boardKey(key), + 'discoveryBlow', + ); +} export function validateWormDevouring( g: Game, t: string, diff --git a/game/engine.ts b/game/engine.ts index f97ef6ec..b3d3d937 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -435,6 +435,7 @@ import { validateStormCancellation, validateWormCancellation, validateWormDevouring, + validateDiscoveryBlow, DisasterPreflightError, } from './disaster-preflight'; import { @@ -4624,7 +4625,7 @@ function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) { observeOccupation(g); log( g, - `${p.name} revealed the ${result.value} No-Field in ${territory(result.location.territory).name} (${cause === 'alliedSeparation' ? 'allied separation' : cause}) and placed ${result.forces} physical forces from reserves.${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`, + `${p.name} revealed the ${result.value} No-Field in ${territory(result.location.territory).name} (${cause === 'alliedSeparation' ? 'allied separation' : cause === 'discoveryBlow' ? 'Discovery spice blow; provisional destructive reveal' : cause}) and placed ${result.forces} physical forces from reserves.${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`, { faction: 'richese', name: 'No-Field revealed' }, ); return result; @@ -13375,8 +13376,8 @@ function resolveDiscoveryBlow(g: Game, card: Extract value.discovery === card.discovery)!; requireRule(card.territory === printed.territory && card.sector === printed.sector && card.amount === printed.amount, 'The Discovery spice blow has changed its printed territory, sector or amount.'); - disasterPreflight(() => validateWormDevouring(g,card.territory,undefined,false)); - for (const p of g.players) killTerritory(g,p,card.territory); + disasterPreflight(() => validateDiscoveryBlow(g,card.territory)); + for (const p of g.players) killTerritory(g,p,card.territory,Infinity,false,'discoveryBlow'); for (const key of Object.keys(g.spice)) if (splitLocation(key).territory === card.territory) delete g.spice[key]; log(g, `A Discovery spice blow destroyed all previous spice and forces in ${territory(card.territory).name}, including Fremen forces. The new spice is placed afterward.`); const placement = DISCOVERY_CARD_PLACEMENTS[card.discovery]; diff --git a/game/reference.ts b/game/reference.ts index 0822ecf6..198f026c 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -5734,6 +5734,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Revealing Ornithopter records who carries it and the turn it was gained. On a later turn, its owner may spend it on one ordinary movement action to give that selected group a fixed range of three. It grants no extra action, adds no shipment and is removed only after the movement commits; saved arrival interactions retain the exact selected group.', 'The five revealed location tokens become separate, initially empty territories inside their surroundings. A private peek does not open the location. Normal ground movement enters through the surrounding territory, shipment uses stronghold prices, at most two factions may occupy it, and forces inside are protected from storms and sandworms. Only Jacurutu Sietch counts toward stronghold victory.', 'Native typed forces enter as their actual ordinary and elite groups, never as passengers from the mobile stronghold or the concealed value of a No-Field. Richese may ship or move a concealed marker into an actually revealed nested location at sector zero; it remains one effective presence until its original reveal materializes available reserves. CHOAM Card Stash uses its five-card limit, drawing first and choosing any held discard afterward.', + 'A printed Discovery blow destroys all old spice and forces in its target territory, including Fremen. A concealed No-Field provisionally reveals, materializes only available reserves and loses those actual counters with its physical companions; zero creates no casualty. This destructive reveal is a selected interpretation. It preserves token history and the original new-spice, storm and face-down placement sequence. Separately identified nested locations and other territories stay outside the parent target. Continue through normal Ready and Accept spice blow controls.', 'At the start of the next turn, before the storm and mobile stronghold movement, each eligible faction may move any positive subset of its non-advisor groups from the surrounding territory into the newly revealed location. The signed choice preserves ordinary and elite forces across sectors, normal source storm and occupancy restrictions, and costs no spice, shipment or normal movement. The printed text gives no sequence and does not restate the source-storm restriction; processing tokens in reveal order and factions in storm order while excluding storm sources are source inferences.', 'After advisor releases, every Cistern fighter occupant receives two bank spice. Orgiz occupants resolve in original storm order using actual ordinary deposit receipts, not mere force presence or old wallets. Advanced charges once per territory; Basic provisionally charges once per positive deposit. The first actual foreign ordinary collector in storm order with available spice pays. Shared Ecaz lots in both modes wait for the actual split, then consider positive foreign recipients Ecaz first, ally second; Advanced counts the territory once and Basic counts actually reduced source deposits. Self, zero shares and empty purses do not qualify. Balances track each transfer without debt; ordering and Basic mapping remain provisional.', 'Jacurutu pays one bank spice for each opposing undialed physical force actually sent to the Tanks. Where an Advanced dial admits different physical counts, the defeated player chooses an exact ordinary/elite/paid reading before support, casualties or rewards settle; unique readings remain automatic. This choice timing is a visibly provisional implementation interpretation. Original Harass returns bind their physical dial and actual withdrawal/retreat counters do not earn spice.', @@ -5746,8 +5747,8 @@ export const RULE_TOPICS: RuleTopic[] = [ checklist: [ { area: 'Implementation', status: 'Partial', - detail: 'Original classic and supported native Skills/Nexus/Discovery entries retain their selected modules and controls. Jacurutu uses an exact defeated-player reading before native settlement; Testing Station queues cumulative choices before one traversal. Cistern pays every fighter occupant. Orgiz uses actual ordinary receipts and bilateral shared allocations in both modes, grouping by territory in Advanced and positive source deposit in Basic without guessed payers or debt. Available balances track all transfers and cumulative overflow. Broader mode/module acceptance remains open.', - evidence: ['game/discovery-module-profile.ts', 'game/richese-no-field.ts', 'game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/board-resolution-quote.ts', 'game/discovery-flight.ts', 'game/discovery-battle.ts', 'game/discovery-storm.ts', 'game/shrine.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'], + detail: 'Concealed No-Fields now follow provisional destructive revelation at a printed Discovery blow through a distinct cause and all-player preflight; only actual reserve-capped counters are lost before original placement. Original classic and supported native Skills/Nexus/Discovery entries retain their selected modules and controls. Jacurutu uses an exact defeated-player reading before native settlement; Testing Station queues cumulative choices before one traversal. Cistern pays every fighter occupant. Orgiz uses actual ordinary receipts and bilateral shared allocations in both modes, grouping by territory in Advanced and positive source deposit in Basic without guessed payers or debt. Available balances track all transfers and cumulative overflow. Broader mode/module acceptance remains open.', + evidence: ['game/disaster-preflight.ts', 'game/discovery-module-profile.ts', 'game/richese-no-field.ts', 'game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/board-resolution-quote.ts', 'game/discovery-flight.ts', 'game/discovery-battle.ts', 'game/discovery-storm.ts', 'game/shrine.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'], }, { area: 'Player controls', status: 'Partial', @@ -5761,13 +5762,13 @@ export const RULE_TOPICS: RuleTopic[] = [ }, { area: 'Documentation', status: 'Partial', - detail: 'Sourced identities and native/classic-skill boundaries are recorded. Advanced Orgiz uses territory Collection; Basic retains its provisional deposit mapping, including actual source reductions after bilateral shared settlement. Recommended occupant/payer ordering, Jacurutu allocation and cumulative Testing Station choices are visible interpretations, not publisher rulings. Great Maker and entry/source-storm inferences remain explicit; full acceptance stays open.', - evidence: ['docs/DISCOVERY_COMPONENTS.md', 'docs/DISCOVERY_PROTOTYPE.md'], + detail: 'The destructive No-Field reveal and separate nested-territory interpretation are explicit provisional compositions, without a specific combined publisher ruling. Sourced identities and native/classic-skill boundaries are recorded. Advanced Orgiz uses territory Collection; Basic retains its provisional deposit mapping, including actual source reductions after bilateral shared settlement. Recommended occupant/payer ordering, Jacurutu allocation and cumulative Testing Station choices are visible interpretations, not publisher rulings. Great Maker and entry/source-storm inferences remain explicit; full acceptance stays open.', + evidence: ['docs/DISCOVERY_COMPONENTS.md', 'docs/DISCOVERY_PROTOTYPE.md', 'docs/RICHESE_NO_FIELD_RULES.md'], }, { area: 'Verification', status: 'Partial', detail: 'Independent Discovery adds frozen15-file118/118, types/lint/build and six original-action/rendered-control programmes; broader modes, visual/deployed acceptance and final assurance remain open. Current Collection batch passes80/80 in6 related files, types/lint and build. Genuine Basic/Advanced cases cover shared equal/full/zero splits, acceptance timing, wrong actors and stale events; helper boundaries cover multiple deposits, exhausted recipients, self/zero-share exclusion and cumulative overflow. Actual390px Basic Red Chasm lot4 changes Ecaz7/Guild8 to8/11 with one transfer and Ready restored, no overflow or errors. Four legal policies complete that Basic allocation via actual actions. Earlier canonical-source and Jacurutu/Station evidence remains source-bound; combinations, assurance, strategy and deployed acceptance remain open.', - evidence: ['tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts', + evidence: ['tests/fixture-discovery-no-field-blow.ts', 'tests/discovery-no-field-blow-engine.test.ts', 'tests/discovery-no-field-blow-preflight.test.ts', 'tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts', 'tests/fixture-jacurutu-mixed-dial.ts', 'tests/jacurutu-mixed-dial-runtime.test.ts', 'tests/fixture-testing-station-contest.ts', 'tests/testing-station-contest-runtime.test.ts', 'tests/fixture-discovery-occupied-collection.ts', 'tests/discovery-occupied-collection-runtime.test.ts', 'tests/fixture-discovery-shared-orgiz.ts', 'tests/discovery-shared-orgiz-runtime.test.ts', 'tests/board-resolution-quote.test.ts', 'tests/ecaz-collection-quote.test.ts', diff --git a/game/richese-no-field.ts b/game/richese-no-field.ts index 75ccf9da..31787d31 100644 --- a/game/richese-no-field.ts +++ b/game/richese-no-field.ts @@ -26,7 +26,8 @@ export type NoFieldRevealCause = | 'beforeAlly' | 'allyShipment' | 'atomics' - | 'alliedSeparation'; + | 'alliedSeparation' + | 'discoveryBlow'; export type NoFieldReveal = { state: RicheseNoField; tokenId: string; @@ -263,6 +264,7 @@ export function revealRicheseNoField( 'allyShipment', 'atomics', 'alliedSeparation', + 'discoveryBlow', ].includes(input.cause), 'Choose a valid No-Field reveal cause.', ); diff --git a/tests/discovery-no-field-blow-engine.test.ts b/tests/discovery-no-field-blow-engine.test.ts new file mode 100644 index 00000000..90cad7f2 --- /dev/null +++ b/tests/discovery-no-field-blow-engine.test.ts @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; +import type { Difficulty } from '../game/bot-profiles'; +import { DISCOVERY_SPICE_CARDS, DISCOVERY_TOKENS } from '../game/discoveries'; +import { actDiscoveryNoFieldBlow as act, createDiscoveryNoFieldBlowFixture as fixture, + discoveryNoFieldBlowInventory as inventory, discoveryNoFieldBlowPlayer as p, + discoveryNoFieldBlowReload as reload, finishDiscoveryNoFieldBlowPhase as finish } from './fixture-discovery-no-field-blow'; + +function assertBlow(f: ReturnType, game: ReturnType) { + const before = p(f.game, f.owner), after = p(game, f.owner), amount = Math.min(f.value, before.reserves); + assert.equal(game.phase, 1); + assert.equal(game.spiceWindow?.territory, f.target); assert.equal(game.spiceWindow?.amount, f.printed.amount); + assert.equal(after.noField!.deployed, null); assert.notEqual(after.noFieldEvent, f.event); + assert.deepEqual(after.noField!.tokens, f.marker!.tokens); assert.equal(after.noField!.lastShipped, f.token); + assert.equal(after.reserves, before.reserves - amount); + assert.equal(after.tanks, before.tanks + amount + Number(f.companions)); + assert.equal(Object.entries(after.forces).some(([key, count]) => count > 0 && key.startsWith(`${f.target}:`)), false); + assert.equal(game.spice[f.location], f.printed.amount); + const placed = game.discoveries!.tokens.filter(token => token.status === 'placed'); assert.equal(placed.length, 1); + assert.equal(placed[0].type, f.placement.type); assert.equal(placed[0].territory, f.placement.territory); + assert.equal(placed[0].sector, f.placement.sector); assert.equal(placed[0].revealedTurn, null); + assert.equal(p(game, f.other).forces['polar_sink:0'], 1); assert.equal(p(game, f.other).tanks, 0); + assert.deepEqual(p(game, f.observer).forces, p(f.game, f.observer).forces); + assert.deepEqual(inventory(game), f.original); +} + +void test('public Basic and Advanced Discovery sources earn paid marker custody before the original later draw', () => { + for (const advanced of [false, true]) { + const f = fixture({ advanced }); + assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); + const optIn = f.sourceActions.findIndex(step => step.action.type === 'moduleOption' && step.action.module === 'discoveries'); + const firstReady = f.sourceActions.findIndex(step => step.action.type === 'ready'); + const start = f.sourceActions.findIndex(step => step.action.type === 'start'); + assert.ok(optIn >= 0 && optIn < firstReady && firstReady < start); + assert.equal(f.setup.discoveries!.tokens.length, DISCOVERY_TOKENS.length); + assert.deepEqual(f.setup.spiceDeck.flatMap(card => 'territory' in card && card.discovery ? [card.discovery] : []).sort(), + DISCOVERY_SPICE_CARDS.map(card => card.discovery).sort()); + assert.equal(f.sourceCounts.setup, f.setupActions.length); assert.equal(f.sourceCounts.source, f.sourceActions.length); + assert.equal(f.sourceCounts.markerShipment, f.markerShipment.steps.length); assert.equal(f.sourceCounts.readyPrefix, f.readyPrefix.length); + assert.equal(p(f.markerShipment.before, f.owner).spice - p(f.markerShipment.after, f.owner).spice, 2); + assert.equal(p(f.markerShipment.before, f.owner).reserves, p(f.game, f.owner).reserves); + assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 1); assert.equal(f.game.spiceWindow ?? null, null); + assert.equal(f.drawStep.actor, f.drawer); assert.deepEqual(f.drawStep.action, { type: 'ready' }); + assert.equal(f.game.ready.includes(f.drawer), false); assert.equal(p(f.game, f.owner).noFieldEvent, f.event); + assert.deepEqual(inventory(f.game), f.original); + } +}); + +void test('Basic actual Discovery blow reveals concealed zero without adding counters before new spice and token placement', () => { + const f = fixture({ advanced: false, value: 0 }), game = act(reload(f.game), f.drawer, f.drawAction); + assertBlow(f, game); assert.equal(p(game, f.owner).tanks, 0); + assert.equal(p(game, f.owner).reserves, p(f.game, f.owner).reserves); + assert.ok(game.log.some(entry => entry.text.includes('Discovery spice blow; provisional destructive reveal'))); +}); + +void test('Advanced original Discovery blow materializes concealed three and five then destroys all returned counters once', () => { + for (const value of [3, 5] as const) { + const f = fixture({ value }); let game = act(reload(f.game), f.drawer, f.drawAction); assertBlow(f, game); + const event = p(game, f.owner).noFieldEvent; + game = finish(game); assert.equal(game.phase, 2); assert.equal(p(game, f.owner).tanks, value); + assert.equal(p(game, f.owner).noFieldEvent, event); assert.equal(p(game, f.owner).noField!.deployed, null); + assert.equal(game.discoveries!.tokens.filter(token => token.status === 'placed').length, 1); + assert.deepEqual(inventory(game), f.original); + } +}); + +void test('the genuine earlier physical companion dies with its later five marker while the separate Polar force survives', () => { + const f = fixture({ value: 5, companions: true }); assert.ok(f.physicalShipment); + assert.equal(f.physicalShipment.before.turn, 1); assert.equal(f.markerShipment.before.turn, 2); assert.equal(f.game.turn, 3); + assert.equal(p(f.physicalShipment.before, f.owner).spice - p(f.physicalShipment.after, f.owner).spice, 2); + assert.equal(p(f.game, f.owner).forces[f.location], 1); assert.equal(p(f.game, f.other).forces['polar_sink:0'], 1); + const game = act(reload(f.game), f.drawer, f.drawAction); assertBlow(f, game); + assert.equal(p(game, f.owner).tanks, 6); assert.equal(p(game, f.owner).reserves, p(f.game, f.owner).reserves - 5); + assert.equal(game.spice[f.location], 6); assert.equal(p(game, f.other).forces['polar_sink:0'], 1); +}); + +void test('an actual voluntary reveal before the same draw leaves ordinary counters for the blow without another reveal', () => { + const f = fixture({ value: 3 }); + let game = act(reload(f.game), f.owner, { type: 'revealNoField', event: f.event, token: f.token }); + const event = p(game, f.owner).noFieldEvent; + assert.notEqual(event, f.event); assert.equal(p(game, f.owner).forces[f.location], 3); + assert.equal(p(game, f.owner).tanks, 0); assert.equal(game.spiceWindow ?? null, null); + game = act(game, f.drawer, f.drawAction); + assert.equal(p(game, f.owner).noFieldEvent, event); assert.equal(p(game, f.owner).tanks, 3); + assert.equal(p(game, f.owner).forces[f.location] ?? 0, 0); assert.equal(game.spice[f.location], 6); + assert.equal(game.discoveries!.tokens.filter(token => token.status === 'placed').length, 1); + assert.deepEqual(inventory(game), f.original); +}); + +void test('all four minimal policies execute the actual pending first-reveal Ready and continue the same blow legally', () => { + for (const advanced of [false, true]) { + const f = fixture({ advanced, value: advanced ? 3 : 0 }); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) { + const view = viewGame(f.game, f.drawer); view.players.find(player => player.id === f.drawer)!.bot = difficulty; + const ready = botActions(view).find(action => action.type === 'ready'); assert.ok(ready); + let game = act(reload(f.game), f.drawer, ready); assertBlow(f, game); + game = finish(game); assert.equal(game.phase, 2); assert.equal(p(game, f.owner).tanks, f.value); + assert.equal(p(game, f.owner).noField!.deployed, null); assert.equal(p(game, f.other).forces['polar_sink:0'], 1); + assert.deepEqual(inventory(game), f.original); + } + } +}); diff --git a/tests/discovery-no-field-blow-preflight.test.ts b/tests/discovery-no-field-blow-preflight.test.ts new file mode 100644 index 00000000..3c20cc53 --- /dev/null +++ b/tests/discovery-no-field-blow-preflight.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, newPlayer } from '../game/engine'; +import { DISCOVERY_SPICE_CARDS } from '../game/discoveries'; +import { territory } from '../game/board'; +import { + DisasterPreflightError, validateDiscoveryBlow, validateWormDevouring, +} from '../game/disaster-preflight'; +import { + createRicheseNoField, deployRicheseNoField, type NoFieldLocation, type NoFieldValue, +} from '../game/richese-no-field'; + +const printed = DISCOVERY_SPICE_CARDS.find(card => card.territory === 'hagga_basin')!; +const sourceKey = `${printed.territory}:${printed.sector}`; + +/** Controlled helper inputs, not a native draw/deployment chronology. The + * marker, armies and reserve/Tanks populations are assigned explicitly so the + * preflight's effect-specific casualty validation can be tested in isolation. */ +function fixture({ value = 5, reserves = 20, location = { + territory: printed.territory, sector: printed.sector, +} }: { value?: NoFieldValue; reserves?: number; location?: NoFieldLocation } = {}) { + const game = createGame('DISCOVERYBLOWPREFLIGHT', newPlayer('r', 'Richese', 'richese'), true, ['choam']); + game.players.push(newPlayer('f', 'Fremen', 'fremen'), newPlayer('e', 'Emperor', 'emperor')); + Object.assign(game, { status: 'playing', phase: 1, turn: 2 }); + const richese = game.players.find(player => player.id === 'r')!; + const fremen = game.players.find(player => player.id === 'f')!; + const emperor = game.players.find(player => player.id === 'e')!; + const stock = createRicheseNoField(['controlled-zero', 'controlled-three', 'controlled-five']); + const token = stock.tokens.find(candidate => candidate.value === value)!; + richese.noField = deployRicheseNoField(stock, { tokenId: token.id, controller: richese.id, location }); + richese.noFieldEvent = 'controlled-before-blow-event'; + richese.reserves = reserves; + richese.tanks = 20 - reserves; + return { game, richese, fremen, emperor }; +} + +void test('Discovery preflight accepts zero three and five markers with scarce or empty reserves without revealing or mutating', () => { + for (const [value, reserves] of [[0, 0], [0, 20], [3, 0], [3, 2], [5, 2], [5, 20]] as const) { + const { game } = fixture({ value, reserves }), before = structuredClone(game); + assert.doesNotThrow(() => validateDiscoveryBlow(game, printed.territory)); + assert.deepEqual(game, before); + } +}); + +void test('Discovery casualty capacity uses the reserve-capped reveal and never counts a zero marker as one counter', () => { + // Numeric capacity-boundary supplements, deliberately not physical game + // populations: the existing guard rejects an unsafe post-loss Tanks total. + const capped = fixture({ value: 5, reserves: 2 }); + capped.richese.tanks = Number.MAX_SAFE_INTEGER - 2; + const before = structuredClone(capped.game); + assert.doesNotThrow(() => validateDiscoveryBlow(capped.game, printed.territory)); + assert.deepEqual(capped.game, before); + capped.richese.tanks++; + const invalid = structuredClone(capped.game); + assert.throws(() => validateDiscoveryBlow(capped.game, printed.territory), /tank capacity/); + assert.deepEqual(capped.game, invalid); + + const zero = fixture({ value: 0, reserves: 20 }); + zero.richese.tanks = Number.MAX_SAFE_INTEGER; + assert.doesNotThrow(() => validateDiscoveryBlow(zero.game, printed.territory)); +}); + +void test('mixed same-territory physical and elite groups validate every sector before accepting Discovery casualties', () => { + const { game, richese, fremen, emperor } = fixture({ value: 3, reserves: 2 }); + const anotherSector = territory(printed.territory).sectors.find(sector => sector !== printed.sector); + assert.notEqual(anotherSector, undefined); + const otherKey = `${printed.territory}:${anotherSector}`; + richese.forces = { [sourceKey]: 2 }; richese.tanks = 16; + fremen.forces = { [sourceKey]: 2, [otherKey]: 1 }; fremen.reserves = 17; + fremen.elites = { reserves: 2, tanks: 0, forces: { [otherKey]: 1 }, revived: 0 }; + emperor.forces = { [otherKey]: 3 }; emperor.reserves = 17; + emperor.elites = { reserves: 1, tanks: 0, forces: { [otherKey]: 2 }, revived: 0 }; + const before = structuredClone(game); + assert.doesNotThrow(() => validateDiscoveryBlow(game, printed.territory)); + assert.deepEqual(game, before); + + const tooManyElites = structuredClone(game); + tooManyElites.players.find(player => player.id === emperor.id)!.elites!.forces[otherKey] = 4; + assert.throws(() => validateDiscoveryBlow(tooManyElites, printed.territory), /ordinary and elite forces/); + const eliteOnly = structuredClone(game); + delete eliteOnly.players.find(player => player.id === emperor.id)!.forces[otherKey]; + assert.throws(() => validateDiscoveryBlow(eliteOnly, printed.territory), /ordinary and elite forces/); +}); + +void test('an unrelated or explicitly nested Discovery marker stays outside the parent blow casualty set', () => { + for (const location of [ + { territory: 'imperial_basin', sector: 10 }, + { territory: 'cistern', sector: 0 }, + ]) { + const { game, richese } = fixture({ value: 5, reserves: 0, location }); + // This explicit nested ID remains a separate location. It is not the + // printed parent territory even if that token is placed inside the parent. + const before = structuredClone(game); + assert.doesNotThrow(() => validateDiscoveryBlow(game, printed.territory)); + assert.deepEqual(game, before); + assert.deepEqual(richese.noField?.deployed?.location, location); + } +}); + +void test('Discovery checks Fremen and protected-allied casualties that an ordinary worm may skip', () => { + for (const actor of ['f', 'e']) { + const { game, fremen, emperor } = fixture(); + fremen.ally = emperor.id; emperor.ally = fremen.id; + game.players.find(player => player.id === actor)!.forces[sourceKey] = 0.5; + assert.doesNotThrow(() => validateWormDevouring(game, printed.territory, actor === 'e' ? emperor.id : undefined)); + const before = structuredClone(game); + assert.throws(() => validateDiscoveryBlow(game, printed.territory), /ordinary and elite forces/); + assert.deepEqual(game, before); + } +}); + +void test('only the six printed Discovery blow territories are accepted as targets', () => { + const { game } = fixture(); + for (const card of DISCOVERY_SPICE_CARDS) + assert.doesNotThrow(() => validateDiscoveryBlow(game, card.territory)); + const before = structuredClone(game); + for (const target of ['arrakeen', 'imperial_basin', 'polar_sink', 'cistern', 'unknown-territory']) + assert.throws(() => validateDiscoveryBlow(game, target), DisasterPreflightError); + assert.deepEqual(game, before); +}); diff --git a/tests/fixture-discovery-no-field-blow.ts b/tests/fixture-discovery-no-field-blow.ts new file mode 100644 index 00000000..e38a17b1 --- /dev/null +++ b/tests/fixture-discovery-no-field-blow.ts @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries'; +import type { NoFieldValue } from '../game/richese-no-field'; +import { actFamilyAtomicsNoField, familyAtomicsNoFieldPlayer, familyAtomicsNoFieldReload, + familyAtomicsNoFieldInventory, familyAtomicsNoFieldClean, nextFamilyAtomicsNoFieldStep, + type FamilyAtomicsNoFieldStep } from './fixture-family-atomics-no-field'; + +export type DiscoveryNoFieldBlowStep = FamilyAtomicsNoFieldStep; +export const actDiscoveryNoFieldBlow = actFamilyAtomicsNoField; +export const discoveryNoFieldBlowPlayer = familyAtomicsNoFieldPlayer; +export const discoveryNoFieldBlowReload = familyAtomicsNoFieldReload; +export const discoveryNoFieldBlowClean = familyAtomicsNoFieldClean; +export function discoveryNoFieldBlowInventory(game: Game) { + const original = familyAtomicsNoFieldInventory(game); validateDiscoveryState(game.discoveries!); return original; +} +export function nextDiscoveryNoFieldBlow(game: Game): DiscoveryNoFieldBlowStep | null { + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + const d = game.decision; + if (d?.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: false } }; + if (d?.kind === 'richeseAllyOpportunity') return { actor: d.player, action: { type: 'decision', decline: true } }; + if (d?.kind === 'discoveryEntry' || d?.kind === 'greatMakerRide') + return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } }; + } + // The inherited native phase5 branch explicitly ends the current turn before + // optional policy actions, including newly supported No-Field transport. + return nextFamilyAtomicsNoFieldStep(game); +} +export function advanceDiscoveryNoFieldBlow(game: Game, until: (state: Game) => boolean, + actions: DiscoveryNoFieldBlowStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original Discovery chronology ended before its requested boundary'); + const next = nextDiscoveryNoFieldBlow(game); assert.ok(next, 'Complete the actual native pending control'); + game = actDiscoveryNoFieldBlow(game, next.actor, next.action, actions); + } + throw Error('The original Discovery/No-Field chronology did not reach its finite boundary'); +} +export function settleDiscoveryNoFieldBlow(game: Game, actions: DiscoveryNoFieldBlowStep[] = []): Game { + return advanceDiscoveryNoFieldBlow(game, discoveryNoFieldBlowClean, actions); +} +export function finishDiscoveryNoFieldBlowPhase(game: Game, actions: DiscoveryNoFieldBlowStep[] = []): Game { + return advanceDiscoveryNoFieldBlow(game, state => state.phase !== 1 && discoveryNoFieldBlowClean(state), actions); +} + +const printed = DISCOVERY_SPICE_CARDS.find(card => card.discovery === 'discovery-wind-pass-north')!; +function selectLand(game: Game, position: number) { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery && + card.territory !== printed.territory && card.sector !== game.storm); + assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card); + return { position, from, card: structuredClone(card) }; +} + +/** Public opt-in and actual shipments only. Marker-only sources use turn1 + * shipment and turn2 original blow; mixed sources first ship physical1, then + * the marker on turn2, and wait for the actual turn3 blow. No audit initializer + * or installed hand, wallet, army, phase, token, event or receipt is used. + */ +export function createDiscoveryNoFieldBlowFixture(options: { + advanced?: boolean; value?: NoFieldValue; companions?: boolean; +} = {}) { + const advanced = options.advanced ?? true, value = options.value ?? 0, companions = options.companions ?? false; + const owner = 'richese', other = 'emperor', observer = 'atreides'; + let game = createGame('DISCOVERYNFBLOW', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']); + joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); joinGame(game, newPlayer(other, 'Emperor', 'emperor')); + const initial = structuredClone(game), actions: DiscoveryNoFieldBlowStep[] = []; + const act = (actor: string, action: Action) => { game = actDiscoveryNoFieldBlow(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceDiscoveryNoFieldBlow(game, until, actions); }; + const transition = (run: () => void) => { + const before = structuredClone(game), from = actions.length; run(); + return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; + }; + act(game.host, { type: 'moduleOption', module: 'discoveries', enabled: true }); + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length)); + const ordinary = ['projectile', 'shield', 'snooper'].map(kind => game.deck.find(card => card.kind === kind)); + const dealOrder = ordinary.map((card, position) => { + assert.ok(card); const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: card.id }; + }); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game), setupActions = structuredClone(actions); + assert.equal(game.discoveryEnabled, true); assert.ok(game.discoveries); + assert.deepEqual(discoveryNoFieldBlowPlayer(game, owner).forces, {}); + const original = discoveryNoFieldBlowInventory(game), originalDiscovery = structuredClone(game.discoveries); + const firstSpiceOrder = [selectLand(game, 0), ...(advanced ? [selectLand(game, 1)] : [])]; + reach(state => state.turn === 1 && state.phase === 5 && discoveryNoFieldBlowClean(state)); + let markerShipment: { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game } | null = null; + let physicalShipment: { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game } | null = null; + let otherShipment: { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game } | null = null; + let token = ''; + const shipMarker = () => { + const own = discoveryNoFieldBlowPlayer(game, owner), selected = own.noField!.tokens.find(token => token.value === value); + assert.ok(selected && own.noFieldEvent); token = selected.id; + markerShipment = transition(() => { + act(owner, { type: 'ship', noField: token, event: own.noFieldEvent, territory: printed.territory, sector: printed.sector }); + reach(discoveryNoFieldBlowClean); + }); + assert.equal(discoveryNoFieldBlowPlayer(markerShipment.before, owner).spice - discoveryNoFieldBlowPlayer(game, owner).spice, 2); + assert.equal(discoveryNoFieldBlowPlayer(markerShipment.before, owner).reserves, discoveryNoFieldBlowPlayer(game, owner).reserves); + }; + for (let count = 0; game.phase === 5 && count < 80; count++) { + if (discoveryNoFieldBlowClean(game) && game.active === owner && !discoveryNoFieldBlowPlayer(game, owner).shipped) { + if (companions) physicalShipment = transition(() => { + act(owner, { type: 'ship', territory: printed.territory, sector: printed.sector, amount: 1, elite: 0, allyPayment: 0 }); + reach(discoveryNoFieldBlowClean); + }); + else shipMarker(); + continue; + } + if (discoveryNoFieldBlowClean(game) && game.active === other && !discoveryNoFieldBlowPlayer(game, other).shipped) { + otherShipment = transition(() => { + act(other, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 }); + reach(discoveryNoFieldBlowClean); + }); + continue; + } + const next = nextDiscoveryNoFieldBlow(game); assert.ok(next); act(next.actor, next.action); + } + assert.ok(otherShipment); assert.ok(!companions || physicalShipment); + reach(state => state.turn === 2 && state.phase === 0 && discoveryNoFieldBlowClean(state)); + let secondSpiceOrder: ReturnType[] = []; + if (companions) { + secondSpiceOrder = [selectLand(game, 0), ...(advanced ? [selectLand(game, 1)] : [])]; + reach(state => state.turn === 2 && state.phase === 5 && state.active === owner && discoveryNoFieldBlowClean(state)); + shipMarker(); + reach(state => state.turn === 3 && state.phase === 0 && discoveryNoFieldBlowClean(state)); + } + assert.ok(markerShipment); + const shipment = markerShipment as { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game }; + const beforeTargetStorm = structuredClone(game); + const from = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed.discovery); assert.ok(from >= 0); + const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.unshift(card); + const blowSelection = { position: 0, from, card: structuredClone(card) }; + const followingSpiceOrder = advanced ? [selectLand(game, 1)] : []; + const turn = companions ? 3 : 2; + reach(state => state.turn === turn && state.phase === 1 && discoveryNoFieldBlowClean(state) && + !state.spiceWindow && !state.spiceSequence && !state.spiceResolution); + const opening = structuredClone(game), readyFrom = actions.length; + assert.equal(game.ready.length, 0); + for (const player of game.players.slice(0, -1)) act(player.id, { type: 'ready' }); + const readyPrefix = structuredClone(actions.slice(readyFrom)); + const drawer = game.players.at(-1)!.id, drawAction: Action = { type: 'ready' }; + assert.equal(game.spiceWindow ?? null, null); assert.equal(game.thumperWindowOpen, true); + assert.deepEqual(game.spiceDeck[0], printed); + assert.equal(game.ready.length, game.players.length - 1); assert.equal(game.ready.includes(drawer), false); + const own = discoveryNoFieldBlowPlayer(game, owner), event = own.noFieldEvent; assert.ok(event); + assert.equal(own.noField!.deployed!.tokenId, token); + assert.equal(event, discoveryNoFieldBlowPlayer(shipment.after, owner).noFieldEvent); + assert.equal(own.forces[`${printed.territory}:${printed.sector}`] ?? 0, companions ? 1 : 0); + assert.equal(own.tanks, 0); assert.equal(discoveryNoFieldBlowPlayer(game, other).forces['polar_sink:0'], 1); + assert.deepEqual(discoveryNoFieldBlowInventory(game), original); assert.deepEqual(game.discoveries, originalDiscovery); + return { game, beforeBlow: structuredClone(game), opening, initial, lobby, setup, afterSetup, + advanced, value, companions, owner, actor: drawer, drawer, other, observer, token, event, + marker: structuredClone(own.noField), printed, target: printed.territory, location: `${printed.territory}:${printed.sector}`, + placement: DISCOVERY_CARD_PLACEMENTS[printed.discovery], drawAction, drawStep: { actor: drawer, action: drawAction }, + markerShipment: shipment, physicalShipment, otherShipment, beforeTargetStorm, original, originalDiscovery, + dealOrder, firstSpiceOrder, secondSpiceOrder, blowSelection, followingSpiceOrder, readyPrefix, + setupActions, actions, sourceActions: actions, sourceActionCount: actions.length, + sourceCounts: { setup: setupActions.length, markerShipment: shipment.steps.length, + physicalShipment: physicalShipment?.steps.length ?? 0, otherShipment: otherShipment.steps.length, + readyPrefix: readyPrefix.length, source: actions.length }, + sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Atreides/Emperor +CHOAM deck, public Discovery moduleOption before readiness/start, no audit initializer or other optional module. ` + + 'Only original unopened Treachery/Spice positions and scalar lotteries are selected. Richese pays two spice for its original concealed0/3/5 token; physical reserves remain untouched. Emperor separately pays2 for one original Polar counter. ' + + (companions ? 'Richese first actually ships physical1 to WindPassNorth17 on turn1, then its marker there on turn2, and reaches the original turn3 Discovery blow. ' + : 'Richese actually ships the marker to WindPassNorth17 on turn1 and reaches the original turn2 Discovery blow. ') + + 'Native dials/Storm, quiet auctions, ordered Advanced Charity/Revival and actual turn ends preserve the same deployed event. Two actual Ready controls prime the original first-reveal window; the exposed final Ready performs the printed draw. No hand, purse, force, phase, token, marker event, spice, discovery placement or casualty is installed.' }; +} +export type DiscoveryNoFieldBlowFixture = ReturnType; diff --git a/tests/fixture-homeworld-classic-discovery.ts b/tests/fixture-homeworld-classic-discovery.ts index e223615a..379b74d8 100644 --- a/tests/fixture-homeworld-classic-discovery.ts +++ b/tests/fixture-homeworld-classic-discovery.ts @@ -40,6 +40,9 @@ export const homeworldClassicDiscoveryPlayer = (game: Game, actor: string) => { /** Public owned continuations only. Storm dials use their original legal range; * no phase clock, casualty, hand, balance or signed Discovery frame is staged. */ export function nextHomeworldClassicDiscoveryStep(game: Game): HomeworldClassicDiscoveryStep { + if (game.status === 'playing' && game.phase === 2 && homeworldClassicDiscoveryClean(game) && + game.charityOrder?.remaining.length) + return { actor: game.charityOrder.remaining[0], action: { type: 'ready' } }; if (!game.phaseOpening && !game.response) { const decision = game.decision; if (decision?.kind === 'homeworldShipmentGuild') diff --git a/tests/richese-no-field.test.ts b/tests/richese-no-field.test.ts index d2ed8ad0..9ef237d6 100644 --- a/tests/richese-no-field.test.ts +++ b/tests/richese-no-field.test.ts @@ -194,6 +194,7 @@ void test('every reveal cause returns capped physical materialization and the ac 'allyShipment', 'atomics', 'alliedSeparation', + 'discoveryBlow', ]; for (const cause of causes) for (const value of [0, 3, 5] as const)