Resolve concealed No-Field losses in Discovery blows

This commit is contained in:
Quintine committed 2026-10-27 13:00:03 +11:00
1 parent 933a9c1379
commit 11e50e7f49
14 files changed
+587 -10

No files matched your search

+37
View File
@@ -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,
+48
View File
@@ -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
+39
View File
@@ -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.**
+20
View File
@@ -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
+14
View File
@@ -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
+17 -1
View File
@@ -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,
+4 -3
View File
@@ -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<SpiceCard, {territory:strin
const printed = DISCOVERY_SPICE_CARDS.find(value => 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];
+6 -5
View File
@@ -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',
+3 -1
View File
@@ -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.',
);
@@ -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<typeof fixture>, game: ReturnType<typeof reload>) {
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);
}
}
});
@@ -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);
});
+171
View File
@@ -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<typeof selectLand>[] = [];
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<typeof createDiscoveryNoFieldBlowFixture>;
@@ -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')
+1
View File
@@ -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)