Support concealed No-Field Planetologist gathering
This commit is contained in:
1 parent
f6e6671ac5
commit
90c50736f2
14 files changed
+817
-42
No files matched your search
@@ -82,17 +82,15 @@ export function planetologistMoveDraft(
|
||||
}
|
||||
const marker = includeNoField ? planetologistNoFieldMarker(game) : null;
|
||||
if (includeNoField) {
|
||||
if (mode !== 'range')
|
||||
return { action: null, blocked: 'A concealed No-Field can use extra range, but cannot join a Planetologist gather.' };
|
||||
if (!marker)
|
||||
return { action: null, blocked: 'Choose your current deployed No-Field.' };
|
||||
if (game.homeworldMobility?.noFieldMovementBlocked)
|
||||
return { action: null, blocked: game.homeworldMobility.noFieldMovementBlocked };
|
||||
if (selected.some(([from]) => splitLocation(from).territory !== marker.location.territory))
|
||||
if (mode === 'range' && selected.some(([from]) => splitLocation(from).territory !== marker.location.territory))
|
||||
return { action: null, blocked: 'The selected No-Field and physical forces must share one origin territory.' };
|
||||
}
|
||||
const origins = new Set(selected.map(([from]) => splitLocation(from).territory));
|
||||
if (marker) origins.add(marker.location.territory);
|
||||
const origins = new Set(marker ? [marker.location.territory] : []);
|
||||
for (const [from] of selected) origins.add(splitLocation(from).territory);
|
||||
if ((mode === 'range' && origins.size !== 1) || (mode === 'gather' && origins.size !== 2))
|
||||
return {
|
||||
action: null,
|
||||
@@ -136,7 +134,7 @@ export function planetologistMoveDraft(
|
||||
|
||||
export function planetologistMovementBlocked(game: GameView, draft: Draft, unavailableReason?: string | null): string | null {
|
||||
const me = game.players.find(player => player.id === game.me);
|
||||
return unavailableReason ??
|
||||
return unavailableReason ||
|
||||
(!me || game.status !== 'playing' || game.phase !== 5 || game.active !== me.id
|
||||
? 'Planetologist movement is available only during your movement turn.'
|
||||
: game.ornithopter?.active
|
||||
|
||||
@@ -91,11 +91,7 @@ function PlanetologistMovementDraft({
|
||||
id={`${id}-mode`}
|
||||
value={mode}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
const nextMode = event.target.value as PlanetologistMoveMode;
|
||||
setMode(nextMode);
|
||||
if (nextMode !== 'range') setIncludeNoField(false);
|
||||
}}
|
||||
onChange={(event) => setMode(event.target.value as PlanetologistMoveMode)}
|
||||
>
|
||||
<option value="range">One territory farther, maximum three</option>
|
||||
<option value="gather">Gather from two territories</option>
|
||||
@@ -111,7 +107,7 @@ function PlanetologistMovementDraft({
|
||||
id={`${id}-no-field`}
|
||||
type="checkbox"
|
||||
checked={includeNoField}
|
||||
disabled={busy || mode !== 'range' || !!game.homeworldMobility?.noFieldMovementBlocked}
|
||||
disabled={busy || !!game.homeworldMobility?.noFieldMovementBlocked}
|
||||
onChange={(event) => setIncludeNoField(event.target.checked)}
|
||||
/>
|
||||
Include your concealed No-Field from {territory(marker.location.territory).name}, sector {marker.location.sector}
|
||||
@@ -122,6 +118,12 @@ function PlanetologistMovementDraft({
|
||||
Move it alone or with physical forces from its own territory; its
|
||||
value stays concealed. No direct joint FAQ settles this combination.
|
||||
</p>
|
||||
<p className="fine" aria-label="Provisional Planetologist No-Field gather">
|
||||
Provisional gather composition: the concealed marker supplies one of
|
||||
exactly two origin territories, alone or with physical forces there.
|
||||
Select physical forces from one other territory. Every origin uses
|
||||
its normal range; gather adds no extra range. Its value stays concealed.
|
||||
</p>
|
||||
{game.homeworldMobility?.noFieldMovementBlocked && (
|
||||
<p className="notice">{game.homeworldMobility.noFieldMovementBlocked}</p>
|
||||
)}
|
||||
@@ -203,7 +205,7 @@ function PlanetologistMovementDraft({
|
||||
busy={busy} unavailableReason={unavailableReason} reasonId={`${id}-reason`} act={act}
|
||||
/>
|
||||
<p className="fine">
|
||||
Concealed No-Fields cannot join gather, either Ornithopter card movement
|
||||
Concealed No-Fields cannot join either Ornithopter card movement
|
||||
or Discovery flight.
|
||||
Normal movement and arrival rules still apply.
|
||||
</p>
|
||||
|
||||
+37
-9
@@ -16,6 +16,34 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Planetologist No-Field gathering — 26 October. Prototyped / Partial.**
|
||||
The own concealed marker and selected physical counters can gather from exactly
|
||||
two territories to one destination. Each selected sector keeps ordinary range;
|
||||
the +1 alternative does not stack. Current trainer/token/event, physical stocks,
|
||||
concealment and one movement remain. Controls retain marker selection across
|
||||
modes and guard actual submission, including empty optional reason text. Minimal
|
||||
policy offers the new legal route; the chronicle names both original territories.
|
||||
This composition remains provisional; special transport and full modes stay open.
|
||||
|
||||
Two disjoint native/control lanes and root integration pass **10 files,87/87**,
|
||||
types/lint/build and **twelve native engine/GameTable SSR/actual selected-props
|
||||
submit-callback paths** on unchanged source. Public R/A/E +choam/Skills starts
|
||||
earn original training, first-turn paid physical shipment and second-turn paid
|
||||
marker shipment for all0/3/5. Companion paths also earn a real split movement and
|
||||
explicit charity claim; no wallet is installed. Basic sources use74/76 actions,
|
||||
Advanced79–82; two-to-three continuation actions reach same-turn Collection.
|
||||
Each retains45 Treachery,14 Skills and15 Traitor cards. Fifteen native cases and
|
||||
twelve new controlled cases cover the route and four minimal profiles. Feedback
|
||||
corrects skipped charity in the source fixture and its controlled autopilot type.
|
||||
|
||||
Frozen v3 report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-planetologist-no-field-gather-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-planetologist-no-field-gather-runtime/`.
|
||||
No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
|
||||
SSR/selected-props callbacks do not certify DOM/hydration, browser/mobile/deployed
|
||||
acceptance, complete combinations or modes. Final assurance remains due.
|
||||
[Source and execution](PLANETOLOGIST_RULES.md#concealed-no-field-two-origin-gathering--26-october-2026).
|
||||
|
||||
**Planetologist and Richese No-Field range — 26 October. Prototyped / Partial.**
|
||||
The owner's concealed marker can use the ordinary extra-range alternative alone
|
||||
or with selected same-territory physical forces. Shared range/entry quotes retain
|
||||
@@ -104,15 +132,15 @@ deployed acceptance. Full Truthtrance, modes/combinations and final assurance
|
||||
remain open. [Battle controls](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md#grouped-battle-plan-authoring--26-october-2026);
|
||||
[shipment controls](COMPOUND_SHIPMENT_PROMISES.md#full-pure-shipment-authoring--26-october-2026).
|
||||
|
||||
Next scoped interaction: Planetologist's two-origin gather with Richese's own
|
||||
concealed marker. The normalized group already records its source and selected
|
||||
physical origins; the remaining gather fence, controls and native earned-source
|
||||
chronology need integration. Each origin must retain its ordinary range, with
|
||||
one shared destination and one movement. This prospective composition is not a
|
||||
located joint FAQ or permission to enable flight/Guild/Homeworld combinations.
|
||||
See [No-Field source](RICHESE_NO_FIELD_RULES.md) and
|
||||
[Planetologist contract](PLANETOLOGIST_RULES.md). Complete rules and final
|
||||
assurance remain unfinished.
|
||||
Next scoped source check: the live Planetologist/card-movement refusal in
|
||||
`validatePlanetologistMove`, against the existing
|
||||
[Ornithopter contract](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md) and
|
||||
[Planetologist contract](PLANETOLOGIST_RULES.md). Distinguish printed movement
|
||||
limits from missing compositions before implementation. Bounded allied-marker
|
||||
review found an existing legal voluntary-reveal→endMovement route; the supplied
|
||||
physical15/36 text does not expressly add alliance separation as a forced reveal
|
||||
trigger. Preserve that route rather than calling it a deadlock or inventing an
|
||||
automatic reveal. Complete rules and final assurance remain unfinished.
|
||||
|
||||
**Truthtrance compound current-fact authoring — 26 October. Prototyped / Partial.**
|
||||
The form now exposes two-through-eight-term AND/OR groups and nesting, with
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Concealed No-Field two-origin Planetologist gathering
|
||||
|
||||
**Prototyped / Partial.** The own marker supplies one of exactly two origins,
|
||||
alone or with its physical companions; physical counters supply the other.
|
||||
Every selected sector keeps ordinary range to one destination, without +1.
|
||||
Current trainer/token/event, concealed0/3/5, original physical stocks and one
|
||||
movement remain. Controls retain marker selection across modes and guard actual
|
||||
submission, including empty optional reason text. Minimal policy offers legal
|
||||
marker gathering; the chronicle records both origins. Composition is provisional,
|
||||
not a joint publisher FAQ. No saved field/action, module gate or special transport
|
||||
permission is added.
|
||||
|
||||
Frozen **10-file87/87**, types/lint/build and **twelve native engine/GameTable SSR/
|
||||
actual selected-props submit-callback paths** pass. Public R/A/E +choam/Skills
|
||||
sources earn training and both paid shipments; companion paths earn the split
|
||||
move and explicit second-turn charity. Basic sources use74/76 actions,
|
||||
Advanced79–82; two-to-three continuation actions finish same-turn Collection.
|
||||
All45 Treachery,14 Skills and15 Traitor cards remain. Fifteen native and twelve
|
||||
new controlled cases include bounds/rejections, controlled eligibility supplements
|
||||
and four minimal profiles. Feedback repairs skipped charity in the native fixture
|
||||
and the controlled autopilot type; earlier failed reports are retained.
|
||||
|
||||
Proof: `/home/quintine/.local/share/dune/checkpoints/20261026-planetologist-no-field-gather-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-planetologist-no-field-gather-runtime/`.
|
||||
No saved-room/D1/server, reset/restart, deployment or recovery operation. SSR/
|
||||
selected-props callbacks do not prove DOM/hydration, browser/mobile/deployed or
|
||||
complete-mode acceptance. Full interactions and final assurance remain due.
|
||||
[Source and execution](PLANETOLOGIST_RULES.md#concealed-no-field-two-origin-gathering--26-october-2026).
|
||||
|
||||
## 26 October 2026 — Concealed No-Field Planetologist extra range
|
||||
|
||||
**Prototyped / Partial.** Compose marker-as-one-force with Planetologist's
|
||||
|
||||
@@ -4,6 +4,50 @@ Updated 26 October 2026. This note audits the movement and battle effects of the
|
||||
Planetologist Leader Skill against its physical card, the common Leader Skills
|
||||
rules, the GF9 base battle rules and designer Jack Reda's walkthrough. The connected prototype is described below; other Leader Skill effects remain separate work.
|
||||
|
||||
## Concealed No-Field two-origin gathering — 26 October 2026
|
||||
|
||||
Compose the printed No-Field marker-as-one-force rule (E2 pp5–6,10–12) with
|
||||
Planetologist's physical two-origin alternative. Under the 14 October authority,
|
||||
the own concealed marker can supply one origin by itself or accompany physical
|
||||
forces there, while selected physical forces supply the second origin. Exactly
|
||||
two distinct territories contribute, and every selected sector must reach one
|
||||
shared destination using its ordinary range. The +1 range alternative does not
|
||||
stack. This is provisional composition, not a located joint publisher FAQ.
|
||||
|
||||
Existing normalization puts the current marker origin first, records both
|
||||
origins and quotes their routes before either commits. Current living trainer,
|
||||
token/event, whole physical groups, path, storm and entry remain authoritative.
|
||||
The same marker relocates concealed, including0; physical counters transfer
|
||||
from their actual sectors. No reserves materialize, no new shipment payment
|
||||
occurs, one movement is consumed and the existing arrival/turn flow resumes.
|
||||
|
||||
The form retains a separate owner-only marker checkbox in either mode. It
|
||||
requires the correct origin count, explains ordinary per-origin gather range
|
||||
and uses the same guarded actual submit action. Empty optional notice text
|
||||
cannot override current movement/response guards. Minimal policy offers a
|
||||
marker plus another physical origin through shared legal geometry; existing
|
||||
positive mixed-marker reveal preference and difficulty strategy remain.
|
||||
|
||||
Physical/Discovery Ornithopter, Guild and Homeworld combinations remain
|
||||
separate. No action kind, saved field, module gate, historical save conversion,
|
||||
new trainer assignment or complete-mode acceptance is introduced.
|
||||
|
||||
**Frozen evidence:** v3 passes10 files,87/87, types/lint/build and twelve native
|
||||
engine/GameTable SSR/actual selected-props submit-callback paths. Public R/A/E
|
||||
+choam/Skills sources earn actual training, both paid shipments and optional
|
||||
split movement/explicit charity. Basic prefixes use74/76 actions; Advanced79–82.
|
||||
Two-to-three continuation actions reach same-turn Collection, retaining45
|
||||
Treachery,14 Skills and15 Traitor cards. Fifteen native and twelve new controlled
|
||||
cases include no stacked +1, exact origins, controlled eligibility supplements,
|
||||
current callback guards and four minimal profiles. Source feedback repairs a
|
||||
skipped charity claim; compile feedback corrects the autopilot fixture type.
|
||||
|
||||
Report/logs: private `20261026-planetologist-no-field-gather-proof-v3/` under
|
||||
`/home/quintine/.local/share/dune/checkpoints/`; journeys/HTML: adjacent
|
||||
`20261026-planetologist-no-field-gather-runtime/`. No saved-room/D1/server, reset/
|
||||
restart, deployment or recovery operation. SSR/selected-props callbacks do not
|
||||
certify DOM/hydration, browser/mobile/deployed acceptance or complete modes.
|
||||
|
||||
## Concealed No-Field ordinary range — 26 October 2026
|
||||
|
||||
The printed No-Field rules (E2 pp5–6,10–12, recorded in the
|
||||
@@ -29,8 +73,9 @@ companions remain ordinary physical counters. Actual callbacks retain movement,
|
||||
ownership, response, automatic-action and busy guards. Minimal policy adds only
|
||||
legal marker routes that need the extra range; no difficulty tuning occurs.
|
||||
|
||||
The two-origin gather alternative, physical/Discovery Ornithopter, Guild and
|
||||
Homeworld combinations retain their separate boundaries. This change adds no
|
||||
At this range checkpoint, gathering retained its separate boundary; the follow-up
|
||||
above adds the ordinary two-origin alternative. Physical/Discovery Ornithopter,
|
||||
Guild and Homeworld combinations remain separate. The range change adds no
|
||||
saved field, new action kind, skill assignment, mode admission or publication
|
||||
acceptance. Full interactions and final browser/deployed assurance remain due.
|
||||
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
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.
|
||||
|
||||
## Planetologist two-origin gathering — 26 October 2026
|
||||
|
||||
The concealed marker may supply one of exactly two contributing territories,
|
||||
alone or with physical companions there; physical counters supply the other.
|
||||
Every selected sector uses ordinary range to one shared destination. The +1
|
||||
alternative does not stack. Current trainer/token/event, exact physical sources,
|
||||
concealment and one movement remain. This marker-as-one-force composition is
|
||||
provisional; no joint FAQ is claimed. No value, including0, materializes early.
|
||||
Controls and minimal policy use existing route/entry quotes. Special flight,
|
||||
Guild and Homeworld combinations remain separate.
|
||||
[Source and execution](PLANETOLOGIST_RULES.md#concealed-no-field-two-origin-gathering--26-october-2026).
|
||||
|
||||
## Planetologist ordinary extra range — 26 October 2026
|
||||
|
||||
The own concealed marker can now use Planetologist's ordinary extra-range
|
||||
@@ -11,8 +23,9 @@ no joint FAQ is claimed. Current token/event, living trainer, actual source,
|
||||
storm/path/entry and one movement remain authoritative. The marker stays
|
||||
concealed, including value0, without materializing or debiting reserves.
|
||||
Controls keep marker and physical counts distinct; minimal policy offers legal
|
||||
boosted marker routes without strategic refinement. Gather, flight, Guild and
|
||||
Homeworld combinations remain separate.
|
||||
boosted marker routes without strategic refinement. The later gathering section
|
||||
above supersedes that checkpoint's gather boundary; flight, Guild and Homeworld
|
||||
combinations remain separate.
|
||||
[Source and execution](PLANETOLOGIST_RULES.md#concealed-no-field-ordinary-range--26-october-2026).
|
||||
|
||||
## Owned movement-promise preparation — 25 October 2026
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Rule decision index
|
||||
|
||||
## Planetologist concealed No-Field gathering — 26 October 2026
|
||||
|
||||
Under 14 October choose-recommended authority, compose the printed moving-marker
|
||||
one-force rule with Planetologist's two-origin alternative. The own marker can
|
||||
supply one territory, alone or with its physical companions; physical counters
|
||||
supply the other. Exactly two origins use ordinary range to one destination,
|
||||
spending one movement without revealing/materializing the value, including0.
|
||||
The +1 alternative does not stack. This is provisional composition, not a joint
|
||||
publisher FAQ. Current trainer/token/event, physical sources and arrival remain.
|
||||
Special flight/Guild/Homeworld boundaries and mode gates stay separate.
|
||||
[Source and execution](PLANETOLOGIST_RULES.md#concealed-no-field-two-origin-gathering--26-october-2026).
|
||||
|
||||
## Planetologist and concealed No-Field range — 26 October 2026
|
||||
|
||||
Under 14 October choose-recommended authority, compose No-Field's printed
|
||||
|
||||
@@ -3718,6 +3718,27 @@ function policyActions(g: GameView): Action[] {
|
||||
gather: false,
|
||||
});
|
||||
}
|
||||
if (marker && noField && !g.homeworldMobility?.noFieldMovementBlocked) {
|
||||
const markerFrom = location(marker.location.territory, marker.location.sector);
|
||||
for (const source of movingSources.filter(candidate =>
|
||||
splitLocation(candidate.from).territory !== marker.location.territory).slice(0, 12))
|
||||
for (const to of usefulTargets.filter(target =>
|
||||
(materialized > 0 || target.enemy === 0) &&
|
||||
botGroundMoveAllowed(g, me, markerFrom, target.key, 0, 'gather') &&
|
||||
botGroundMoveAllowed(g, me, source.from, target.key, source.elite, 'gather')).slice(0, 24))
|
||||
groundMoves.push({
|
||||
action: {
|
||||
type: 'move', planetologist: 'gather',
|
||||
forces: { [source.from]: source.amount },
|
||||
...(me.elites ? { eliteForces: { [source.from]: source.elite } } : {}),
|
||||
noField: marker.tokenId, event: noField.event,
|
||||
territory: to.t, sector: to.s,
|
||||
},
|
||||
destinationScore: to.score,
|
||||
amount: source.amount + 1,
|
||||
gather: true,
|
||||
});
|
||||
}
|
||||
const gatherSources = movingSources.slice(0, 12);
|
||||
for (let first = 0; first < gatherSources.length; first += 1)
|
||||
for (let second = first + 1; second < gatherSources.length; second += 1) {
|
||||
|
||||
+6
-7
@@ -17366,10 +17366,8 @@ function validatePlanetologistMove(g: Game, p: Player, move: Pick<MovementOrder,
|
||||
'Planetologist movement combined with expansion factions is still being implemented.');
|
||||
requireRule(!move.ornithopterEvent && !move.discoveryFlight,
|
||||
'Planetologist combined with special movement cards is still being implemented.');
|
||||
// Selected composition: the concealed marker moves as one force, so the
|
||||
// ordinary extra-range band applies without revealing or materializing it.
|
||||
requireRule(!move.noField || skill.mode === 'range',
|
||||
'Planetologist gathering with a No-Field is still being implemented.');
|
||||
// Selected composition: the concealed marker moves as one force for either
|
||||
// alternative. Gathering retains each origin's ordinary range; no +1 stacks.
|
||||
requireRule(skill.mode === 'gather'
|
||||
? Array.isArray(move.origins) && move.origins.length === 2 && new Set(move.origins).size === 2 && move.origins[0] === move.origin
|
||||
: move.origins === undefined,
|
||||
@@ -18374,14 +18372,15 @@ function completeMove(g: Game, move: MovementOrder) {
|
||||
}
|
||||
/** Forces and movement counters are already committed; only arrival remains. */
|
||||
function finishMovedGroup(g: Game, move: CompletedMovement) {
|
||||
const { player: id, origin, total: n, to, sector: s, elite } = move;
|
||||
const { player: id, total: n, to, sector: s, elite } = move;
|
||||
const p = getPlayer(g, id);
|
||||
const origins = movementOrigins(move).map(source => territory(source).name).join(' and ');
|
||||
g.karamaShipping = null;
|
||||
log(
|
||||
g,
|
||||
move.noField
|
||||
? `${p.name} moved a concealed No-Field${n > 1 ? ` with ${n - 1} physical forces` : ''} from ${territory(origin).name} to ${territory(to).name}. Its value remains concealed.`
|
||||
: `${p.name} moved ${n} forces from ${movementOrigins(move).map(source => territory(source).name).join(' and ')} to ${territory(to).name}.`,
|
||||
? `${p.name} moved a concealed No-Field${n > 1 ? ` with ${n - 1} physical forces` : ''} from ${origins} to ${territory(to).name}. Its value remains concealed.`
|
||||
: `${p.name} moved ${n} forces from ${origins} to ${territory(to).name}.`,
|
||||
{ faction: p.faction, name: 'Movement' },
|
||||
);
|
||||
intrusion(g, p, to);
|
||||
|
||||
+9
-6
@@ -1095,7 +1095,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Richese has three No-Fields, numbered zero, three and five. A concealed token counts as one force. Only one may be deployed on Arrakis, and consecutive own or allied shipments cannot use the same token.',
|
||||
'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.',
|
||||
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use the ordinary extra-range alternative: one territory farther, capped at three. Select the marker alone or with physical forces from its own territory. It stays concealed, including zero, and spends one movement without materializing reserves. Two-origin gathering and special flight combinations remain unfinished.',
|
||||
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Special flight combinations remain unfinished.',
|
||||
'The revealed token stays face up until another token is placed. Revealing it does not clear the previous-shipment restriction. An allied No-Field shipment reveals immediately and uses the ally’s reserves; an existing token must be revealed before another shipment.',
|
||||
'Before an eligible ally uses its shipment, the game holds an offer opportunity for Richese. Richese may pass or privately propose a token, destination and payment. The ally accepts the exact offer and chooses its permitted regular/elite mix, or declines without payment. Success consumes the ally’s shipment, not Richese’s shipment or either player’s movement.',
|
||||
'Ordinary allied shipment costs one spice into a stronghold or two elsewhere. Guild recipients and active purchased prices use the existing provisional bank-only discount. Canceling a native Guild recipient’s rate charges one full-price marker, never zero/five materialized forces. A supplier’s original whole-fee1 approval cannot silently become2; release unchanged and obtain a new full-price offer/consent. Independent purchased price remains. Fremen on-planet reserves stay ineligible.',
|
||||
@@ -1124,36 +1124,39 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Native lifecycle, additive pools, card funding, distinct special inspection and current printed-value Truthtrance remain. Own concealed Planetologist extra-range movement uses current trainer/token/event and ordinary path/entry, preserving one movement, denomination and physical stocks. Gather/flight combinations remain open.',
|
||||
'Native lifecycle, additive pools, card funding, distinct special inspection and current printed-value Truthtrance remain. Own concealed Planetologist extra-range or two-origin gathering uses current trainer/token/event and exact per-origin route/entry, preserving one movement, denomination and physical stocks. Special transport remains separate.',
|
||||
evidence: ['game/shipment-price.ts', 'game/engine.ts', 'game/planetologist-movement.ts', 'game/bot-mobility.ts', 'game/truthtrance.ts', 'game/truthtrance-no-field-value.ts'],
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Original inventory/card/inspection and current-fact controls remain. Planetologist exposes the owner’s marker separately from same-origin counters, with provisional guidance and guarded actual submission. Marker-only selection creates no physical force count.',
|
||||
'Original inventory/card/inspection and current-fact controls remain. Planetologist exposes the owner’s marker separately from physical counters in either movement mode, with provisional guidance, exact origin counts and guarded actual submission. The marker creates no physical force count.',
|
||||
evidence: ['components/richese-allied-no-field.tsx', 'components/richese-no-field.tsx', 'components/planetologist-movement.tsx', 'components/planetologist-movement-action.tsx', 'components/harass-withdraw.tsx', 'components/game-table.tsx', 'components/private-battle-plan.tsx', 'components/mixed-shipment-fields.tsx', 'components/truthtrance-no-field-value.tsx'],
|
||||
},
|
||||
{
|
||||
area: 'AI',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Existing own/allied, inspection and truthAnswer policies remain. All four minimal profiles may offer boosted own-marker routes through the shared normal geometry/entry quote and living trainer. Strategy and calibration remain deferred.',
|
||||
'Existing own/allied, inspection and truthAnswer policies remain. All four minimal profiles may offer boosted own-marker routes or marker-plus-other-origin gathering through shared legal geometry/entry and a living trainer. Existing mixed-marker reveal preference, strategy and calibration remain separate.',
|
||||
evidence: ['game/bots.ts'],
|
||||
},
|
||||
{
|
||||
area: 'Documentation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Current-observation, printed value and physical counter distinctions remain recorded. The marker-as-one-force plus Planetologist ordinary-range composition is explicitly provisional; no joint FAQ or complete combinations are claimed.',
|
||||
'Current-observation, printed value and physical counter distinctions remain recorded. Marker-as-one-force plus either ordinary Planetologist movement alternative is explicitly provisional; no joint FAQ or complete combinations are claimed.',
|
||||
evidence: ['docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_NO_FIELD_RULES.md', 'docs/PLANETOLOGIST_RULES.md', 'docs/ATREIDES_FULL_PLAN_TIMING.md', 'docs/TRUTHTRANCE_FORCE_FACTS.md'],
|
||||
},
|
||||
{
|
||||
area: 'Verification',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Native trained-leader/shipment/movement cases and controlled submit callbacks cover the concealed Planetologist range route and legal continuation. Earlier scoped Truthtrance, inspection, allied and recovery evidence remains historical. Complete modes, broader combinations, browser/mobile/deployed and final assurance remain due.',
|
||||
'Native trained-leader/shipment/movement cases and controlled submit callbacks cover concealed Planetologist range and gather routes plus legal continuation. Earlier scoped Truthtrance, inspection, allied and recovery evidence remains historical. Complete modes, broader combinations, browser/mobile/deployed and final assurance remain due.',
|
||||
evidence: [
|
||||
'tests/fixture-planetologist-no-field-gather.ts',
|
||||
'tests/planetologist-no-field-gather-engine.test.ts',
|
||||
'tests/planetologist-no-field-gather-controls.test.tsx',
|
||||
'tests/fixture-planetologist-no-field.ts',
|
||||
'tests/planetologist-no-field-engine.test.ts',
|
||||
'tests/planetologist-no-field-controls.test.tsx',
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { gameTerritories, location, splitLocation } from '../game/board';
|
||||
import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import { planetologistLeader } from '../game/planetologist-movement';
|
||||
import type { NoFieldValue } from '../game/richese-no-field';
|
||||
import { createSelectedDeckSkillsSetup } from './fixture-selected-deck-skills';
|
||||
import {
|
||||
actPlanetologistNoField, advancePlanetologistNoField, planetologistNoFieldClean,
|
||||
planetologistNoFieldInventory, planetologistNoFieldPhysical, planetologistNoFieldPlayer,
|
||||
type PlanetologistNoFieldStep,
|
||||
} from './fixture-planetologist-no-field';
|
||||
|
||||
export type PlanetologistNoFieldGatherStep = PlanetologistNoFieldStep;
|
||||
export const actPlanetologistNoFieldGather = actPlanetologistNoField;
|
||||
export const advancePlanetologistNoFieldGather = advancePlanetologistNoField;
|
||||
export const planetologistNoFieldGatherClean = planetologistNoFieldClean;
|
||||
export const planetologistNoFieldGatherInventory = planetologistNoFieldInventory;
|
||||
export const planetologistNoFieldGatherPhysical = planetologistNoFieldPhysical;
|
||||
export const planetologistNoFieldGatherPlayer = planetologistNoFieldPlayer;
|
||||
|
||||
function gatherRoutes(game: Game, actor: string, physicalSource: string, markerSource: string) {
|
||||
const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!;
|
||||
const fromTerritories = [physicalSource, markerSource].map(source => splitLocation(source).territory);
|
||||
const emptyRocks = gameTerritories(game).filter(place => place.type === 'rock' &&
|
||||
!fromTerritories.includes(place.id) && !game.players.some(player => presenceAt(player, place.id)))
|
||||
.flatMap(place => place.sectors.map(sector => location(place.id, sector)));
|
||||
const targetLocation = emptyRocks.find(target => [physicalSource, markerSource].every(source =>
|
||||
groundMoveRouteDistance(view, me, source, target) === 1 && botGroundMoveAllowed(view, me, source, target, 0, 'gather')));
|
||||
const tooFarLocation = emptyRocks.find(target => [physicalSource, markerSource].every(source =>
|
||||
botGroundMoveAllowed(view, me, source, target, 0, 'range')) && [physicalSource, markerSource].some(source =>
|
||||
groundMoveRouteDistance(view, me, source, target) === 2));
|
||||
return targetLocation && tooFarLocation ? { targetLocation, tooFarLocation } : null;
|
||||
}
|
||||
|
||||
/** Genuine public R/A/E +choam/Skills. First-turn paid physical shipment and
|
||||
* optional ordinary split precede a real turn-two marker shipment to a second
|
||||
* origin. No played cards, counters, wallets, markers or phases are installed. */
|
||||
export function createPlanetologistNoFieldGatherFixture(options: {
|
||||
advanced?: boolean; value?: NoFieldValue; withMarkerCompanion?: boolean;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, value = options.value ?? 5;
|
||||
const withMarkerCompanion = options.withMarkerCompanion ?? false;
|
||||
const setup = createSelectedDeckSkillsSetup({ advanced, factions: ['richese', 'atreides', 'emperor'],
|
||||
expansions: ['choam'], skill: 'planetologist' });
|
||||
const actor = setup.owner, actions: PlanetologistNoFieldGatherStep[] = [...setup.actions];
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
const act = (owner: string, action: Action) => { game = actPlanetologistNoFieldGather(game, owner, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advancePlanetologistNoFieldGather(game, until, actions); };
|
||||
const originalInventory = planetologistNoFieldGatherInventory(game);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === actor && planetologistNoFieldGatherClean(state));
|
||||
const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!;
|
||||
const rocks = gameTerritories(game).filter(place => place.type === 'rock' &&
|
||||
!game.players.some(player => presenceAt(player, place.id)))
|
||||
.flatMap(place => place.sectors.filter(sector => sector >= 7 && sector <= 16 && sector !== game.storm)
|
||||
.map(sector => location(place.id, sector)));
|
||||
let geometry: { physicalSource: string; markerSource: string } | null = null;
|
||||
for (const physicalSource of rocks) {
|
||||
for (const markerSource of rocks) {
|
||||
if (splitLocation(physicalSource).territory === splitLocation(markerSource).territory ||
|
||||
groundMoveRouteDistance(view, me, physicalSource, markerSource) !== 1 ||
|
||||
!botGroundMoveAllowed(view, me, physicalSource, markerSource, 0) ||
|
||||
!gatherRoutes(game, actor, physicalSource, markerSource)) continue;
|
||||
geometry = { physicalSource, markerSource }; break;
|
||||
}
|
||||
if (geometry) break;
|
||||
}
|
||||
assert.ok(geometry, 'Choose two empty adjacent rock origins with a shared normal-range rock destination');
|
||||
const { physicalSource, markerSource } = geometry;
|
||||
const physicalSourceLocation = splitLocation(physicalSource), sourceLocation = splitLocation(markerSource);
|
||||
const amount = withMarkerCompanion ? 2 : 1;
|
||||
const beforePhysicalShipment = structuredClone(game), physicalShipmentStep: PlanetologistNoFieldGatherStep = { actor,
|
||||
action: { type: 'ship', ...physicalSourceLocation, amount, elite: 0, allyPayment: 0 } };
|
||||
act(actor, physicalShipmentStep.action); reach(planetologistNoFieldGatherClean);
|
||||
const physicalShipment = { before: beforePhysicalShipment, step: physicalShipmentStep, after: structuredClone(game) };
|
||||
assert.equal(planetologistNoFieldGatherPlayer(beforePhysicalShipment, actor).spice -
|
||||
planetologistNoFieldGatherPlayer(game, actor).spice, amount * 2);
|
||||
let splitMovement: { before: Game; step: PlanetologistNoFieldGatherStep; after: Game } | null = null;
|
||||
if (withMarkerCompanion) {
|
||||
const before = structuredClone(game), step: PlanetologistNoFieldGatherStep = { actor,
|
||||
action: { type: 'move', from: physicalSource, amount: 1, elite: 0, ...sourceLocation } };
|
||||
act(actor, step.action); reach(planetologistNoFieldGatherClean);
|
||||
splitMovement = { before, step, after: structuredClone(game) };
|
||||
assert.equal(planetologistNoFieldGatherPlayer(game, actor).forces[physicalSource], 1);
|
||||
assert.equal(planetologistNoFieldGatherPlayer(game, actor).forces[markerSource], 1);
|
||||
}
|
||||
const afterFirstPreparation = structuredClone(game);
|
||||
let charity: { before: Game; step: PlanetologistNoFieldGatherStep; after: Game } | null = null;
|
||||
if (withMarkerCompanion) {
|
||||
reach(state => state.turn === 2 && state.phase === 2 && planetologistNoFieldGatherClean(state));
|
||||
const before = structuredClone(game), step: PlanetologistNoFieldGatherStep = { actor, action: { type: 'charity' } };
|
||||
assert.equal(planetologistNoFieldGatherPlayer(game, actor).spice, 1);
|
||||
act(actor, step.action); reach(planetologistNoFieldGatherClean);
|
||||
charity = { before, step, after: structuredClone(game) };
|
||||
assert.equal(planetologistNoFieldGatherPlayer(game, actor).spice, 2);
|
||||
}
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && planetologistNoFieldGatherClean(state));
|
||||
const current = planetologistNoFieldGatherPlayer(game, actor);
|
||||
assert.equal(current.forces[physicalSource], 1);
|
||||
assert.equal(current.forces[markerSource] ?? 0, Number(withMarkerCompanion));
|
||||
assert.equal(current.moved, 0); assert.equal(current.shipped, false);
|
||||
if (withMarkerCompanion) {
|
||||
assert.equal(planetologistNoFieldGatherPlayer(afterFirstPreparation, actor).spice, 1);
|
||||
assert.equal(current.spice, 2, 'Real next-turn charity funds the second paid shipment');
|
||||
}
|
||||
const token = current.noField!.tokens.find(candidate => candidate.value === value); assert.ok(token);
|
||||
assert.equal(current.noField!.deployed, null);
|
||||
const beforeShipment = structuredClone(game), shipmentStep: PlanetologistNoFieldGatherStep = { actor,
|
||||
action: { type: 'ship', noField: token.id, event: current.noFieldEvent, ...sourceLocation } };
|
||||
act(actor, shipmentStep.action); reach(planetologistNoFieldGatherClean);
|
||||
const afterShipment = structuredClone(game), routes = gatherRoutes(game, actor, physicalSource, markerSource);
|
||||
assert.ok(routes, 'The real second-turn board retains both normal gather routes and the non-stacking negative');
|
||||
assert.equal(planetologistNoFieldGatherPlayer(beforeShipment, actor).spice - planetologistNoFieldGatherPlayer(game, actor).spice, 2);
|
||||
assert.equal(planetologistNoFieldGatherPlayer(beforeShipment, actor).reserves, planetologistNoFieldGatherPlayer(game, actor).reserves);
|
||||
const trainer = planetologistLeader(game, actor); assert.ok(trainer);
|
||||
const forces: Record<string, number> = { [physicalSource]: 1, ...(withMarkerCompanion ? { [markerSource]: 1 } : {}) };
|
||||
const target = splitLocation(routes.targetLocation), tooFar = splitLocation(routes.tooFarLocation);
|
||||
const action: Action = { type: 'move', planetologist: 'gather', forces,
|
||||
noField: token.id, event: planetologistNoFieldGatherPlayer(game, actor).noFieldEvent, ...target };
|
||||
const tooFarAction: Action = { ...action, ...tooFar };
|
||||
assert.deepEqual(planetologistNoFieldGatherInventory(game), originalInventory);
|
||||
return { game, sourceGame: structuredClone(game), initial: setup.initial, started: setup.started,
|
||||
offered: setup.offered, afterSetup: setup.afterSetup, setup, actor, owner: actor, richese: actor,
|
||||
observer: setup.seats.emperor!, opponent: setup.seats.atreides!, advanced, value,
|
||||
token: structuredClone(token), trainer, source: markerSource, markerSource, physicalSource,
|
||||
sourceLocation, physicalSourceLocation, target, targetLocation: routes.targetLocation,
|
||||
tooFar, tooFarLocation: routes.tooFarLocation, tooFarAction, action, forces,
|
||||
withMarkerCompanion, companions: Number(withMarkerCompanion), amount,
|
||||
physicalShipment, normalShipment: physicalShipment, splitMovement, afterFirstPreparation, charity, charityClaim: charity,
|
||||
beforeShipment, shipmentStep, afterShipment, markerShipment: { before: beforeShipment, step: shipmentStep, after: afterShipment },
|
||||
originalInventory, originalPhysical: planetologistNoFieldGatherPhysical(game),
|
||||
originalNoField: structuredClone(planetologistNoFieldGatherPlayer(game, actor).noField!),
|
||||
originalEvent: planetologistNoFieldGatherPlayer(game, actor).noFieldEvent!,
|
||||
endMovement: { type: 'endMovement' } satisfies Action,
|
||||
actions, sourceActionCount: actions.length,
|
||||
staging: [...setup.staging,
|
||||
'Original public Basic/Advanced Richese/Atreides/Emperor +choam/Leader Skills setup and actual private Planetologist training. Only the existing original unopened skill/scalar lotteries are selected; no played card, force, wallet, leader, marker, clock or receipt is installed. Advanced remains preview evidence.',
|
||||
withMarkerCompanion ? 'Actual turn-one two-force shipment costs4. One real ordinary range-one movement leaves one physical counter at each adjacent rock origin. Native turn progression stops at actual turn-two Charity, where Richese explicitly claims its original charity action to raise wallet1 to2, funding the actual concealed-marker shipment beside its companion.'
|
||||
: 'Actual turn-one one-force shipment costs2 and remains at its rock origin. Native end-turn and second Storm/Spice/Charity/Bidding/Revival controls precede the actual turn-two concealed0/3/5 shipment to a different empty rock origin for2.',
|
||||
'Both real origins reach one shared empty rock destination using ordinary range1. Stop before the actual gather action; token/event, all selected physical counters, one current movement and original Collection continuation remain caller-owned outcomes. A separate range2 destination is illegal for gather because the +1 branch does not stack.',
|
||||
] };
|
||||
}
|
||||
export type PlanetologistNoFieldGatherFixture = ReturnType<typeof createPlanetologistNoFieldGatherFixture>;
|
||||
export function movePlanetologistNoFieldGather(fixture: PlanetologistNoFieldGatherFixture,
|
||||
game = structuredClone(fixture.game), actions: PlanetologistNoFieldGatherStep[] = []) {
|
||||
return actPlanetologistNoFieldGather(game, fixture.actor, fixture.action, actions);
|
||||
}
|
||||
export function finishPlanetologistNoFieldGather(fixture: PlanetologistNoFieldGatherFixture, game: Game,
|
||||
actions: PlanetologistNoFieldGatherStep[] = []) {
|
||||
assert.equal(game.turn, fixture.game.turn);
|
||||
return advancePlanetologistNoFieldGather(game, state => state.turn === fixture.game.turn &&
|
||||
state.phase === 7 && planetologistNoFieldGatherClean(state), actions);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ void test('the owned marker has a usable checkbox origin and persistent provisio
|
||||
assert.match(html, /value stays concealed/);
|
||||
assert.match(html, /No direct joint FAQ settles this combination/);
|
||||
assert.match(html, /No physical board forces are available to move/);
|
||||
assert.match(html, /Concealed No-Fields cannot join gather, either Ornithopter card movement or Discovery flight/);
|
||||
assert.match(html, /Concealed No-Fields cannot join either Ornithopter card movement or Discovery flight/);
|
||||
assert.doesNotMatch(html, /controlled-zero|controlled-three|controlled-five|Confirm|Acknowledge|countdown/);
|
||||
}
|
||||
assert.deepEqual(game, before);
|
||||
@@ -125,7 +125,7 @@ void test('the existing six-argument draft keeps physical movement separate from
|
||||
assert.deepEqual(click({ ...propsFor(game), includeNoField: false }), []);
|
||||
});
|
||||
|
||||
void test('the marker cannot join gather or carry physical forces from a second territory', () => {
|
||||
void test('marker gather requires two origins and ordinary range while extra range excludes a second territory', () => {
|
||||
const game = projected(0, true);
|
||||
game.players[0].forces['wind_pass:14'] = 2;
|
||||
for (const props of [
|
||||
@@ -137,7 +137,7 @@ void test('the marker cannot join gather or carry physical forces from a second
|
||||
assert.deepEqual(click(props), []);
|
||||
assert.match(button(renderToStaticMarkup(createElement(PlanetologistMovementAction, props))), /\sdisabled=/);
|
||||
}
|
||||
assert.match(planetologistMoveDraft(game, 'gather', destination, 0, {}, {}, true).blocked!, /cannot join a Planetologist gather/);
|
||||
assert.match(planetologistMoveDraft(game, 'gather', destination, 0, {}, {}, true).blocked!, /exactly two different territories/);
|
||||
});
|
||||
|
||||
void test('exact source and destination sectors and valid whole available counts guard actual submission', () => {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { PlanetologistMovement, planetologistMoveDraft } from '../components/planetologist-movement';
|
||||
import { PlanetologistMovementAction, planetologistMovementBlocked, type PlanetologistMovementActionProps } from '../components/planetologist-movement-action';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { createRicheseNoField, deployRicheseNoField, projectRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
|
||||
|
||||
const markerSource = 'wind_pass:14';
|
||||
const companionSource = 'wind_pass:15';
|
||||
const secondSource = 'wind_pass_north:17';
|
||||
const thirdSource = 'false_wall_west:16';
|
||||
|
||||
// Controlled entitled projections supplement the separate native gather source.
|
||||
// No trainer, army, marker or pending receipt is installed in a Game. React
|
||||
// renders the stateful parent; direct calls enter only the actual submit leaf.
|
||||
function projected(value: NoFieldValue = 0, advanced = false): GameView {
|
||||
const game = createGame('PLANETOLOGISTNOFIELDGATHERCONTROLS', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
const view = viewGame(game, 'r');
|
||||
view.status = 'playing'; view.phase = 5; view.turn = 2; view.storm = 18; view.active = 'r';
|
||||
view.order = ['r', 'a']; view.movementRemaining = ['r', 'a'];
|
||||
const me = view.players[0];
|
||||
me.moved = 0; me.movesAllowed = 1; me.shipped = true;
|
||||
me.forces = { [companionSource]: 2, [secondSource]: 3, [thirdSource]: 1 };
|
||||
const tokens = createRicheseNoField(['gather-zero', 'gather-three', 'gather-five']);
|
||||
const marker = deployRicheseNoField(tokens, {
|
||||
tokenId: tokens.tokens.find(token => token.value === value)!.id,
|
||||
controller: me.id, location: splitLocation(markerSource),
|
||||
});
|
||||
const publicMarker = projectRicheseNoField(marker, false);
|
||||
me.noField = publicMarker;
|
||||
view.richeseNoField = {
|
||||
owner: me.id, event: 'controlled-current-gather-event', public: publicMarker, private: marker,
|
||||
} as NonNullable<GameView['richeseNoField']>;
|
||||
view.leaderSkills = {
|
||||
assignments: [{ owner: me.id, controller: me.id, leader: me.leaders[0].id,
|
||||
skill: 'planetologist', captured: false, faceUp: true }],
|
||||
offer: null, eligibleLeaders: [], battleChoice: null,
|
||||
};
|
||||
return view;
|
||||
}
|
||||
function propsFor(game: GameView, forces: Record<string, number> = { [secondSource]: 1 }): PlanetologistMovementActionProps {
|
||||
return { game, mode: 'gather', destination: 'polar_sink', sector: 0, forces, elites: {},
|
||||
includeNoField: true, busy: false, reasonId: 'controlled-gather-reason', act() {} };
|
||||
}
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(PlanetologistMovement, { game, destination: 'polar_sink', sector: 0, busy, act() {} }));
|
||||
}
|
||||
function button(html: string) {
|
||||
const found = html.match(/<button\b(?=[^>]*data-testid="planetologist-move")[^>]*>[\s\S]*?<\/button>/)?.[0];
|
||||
assert.ok(found); return found;
|
||||
}
|
||||
function click(props: PlanetologistMovementActionProps): Action[] {
|
||||
const actions: Action[] = [];
|
||||
PlanetologistMovementAction({ ...props, act(action) { actions.push(action); } }).props.onClick();
|
||||
return actions;
|
||||
}
|
||||
function expected(game: GameView, forces: Record<string, number>): Action {
|
||||
return { type: 'move', planetologist: 'gather', forces,
|
||||
noField: game.richeseNoField!.private!.deployed!.tokenId, event: game.richeseNoField!.event,
|
||||
territory: 'polar_sink', sector: 0 };
|
||||
}
|
||||
|
||||
void test('owned marker controls explain the provisional two-origin gather and ordinary range without hiding the checkbox', () => {
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
const game = projected(value), before = structuredClone(game);
|
||||
const html = markup(game);
|
||||
const checkbox = html.match(/<input\b[^>]*type="checkbox"[^>]*>/)?.[0];
|
||||
assert.ok(checkbox); assert.doesNotMatch(checkbox, /\sdisabled=/);
|
||||
assert.match(html, /Include your concealed No-Field from Wind Pass, sector 14/);
|
||||
assert.match(html, /aria-label="Provisional Planetologist No-Field gather"/);
|
||||
assert.match(html, /Provisional gather composition: the concealed marker supplies one of exactly two origin territories/);
|
||||
assert.match(html, /alone or with physical forces there/);
|
||||
assert.match(html, /Select physical forces from one other territory/);
|
||||
assert.match(html, /Every origin uses its normal range; gather adds no extra range/);
|
||||
assert.match(html, /Its value stays concealed/);
|
||||
assert.doesNotMatch(html, /gather-zero|gather-three|gather-five|cannot join gather|Confirm|Acknowledge/);
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual gather callbacks accept a marker-only first origin and exact second-origin physical forces including concealed zero', () => {
|
||||
for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) {
|
||||
const game = projected(value, advanced), before = structuredClone(game);
|
||||
assert.equal(game.players[0].forces[markerSource], undefined);
|
||||
const forces = { [secondSource]: 2 };
|
||||
const draft = planetologistMoveDraft(game, 'gather', 'polar_sink', 0, forces, {}, true);
|
||||
assert.equal(draft.blocked, null);
|
||||
assert.deepEqual(draft.action, expected(game, forces));
|
||||
assert.deepEqual(click(propsFor(game, forces)), [expected(game, forces)]);
|
||||
assert.deepEqual(Object.keys(click(propsFor(game, forces))[0]).sort(), ['event', 'forces', 'noField', 'planetologist', 'sector', 'territory', 'type']);
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('same-territory physical companions stay in the marker origin while the second physical origin remains exact', () => {
|
||||
const game = projected(5), before = structuredClone(game);
|
||||
const forces = { [companionSource]: 2, [secondSource]: 3 };
|
||||
assert.deepEqual(click(propsFor(game, forces)), [expected(game, forces)]);
|
||||
const withZeroThird = { ...forces, [thirdSource]: 0 };
|
||||
assert.deepEqual(click(propsFor(game, withZeroThird)), [expected(game, forces)]);
|
||||
const physicalOnly = planetologistMoveDraft(game, 'gather', 'polar_sink', 0, forces, {});
|
||||
assert.equal(physicalOnly.blocked, null);
|
||||
assert.deepEqual(physicalOnly.action, { type: 'move', planetologist: 'gather', forces, territory: 'polar_sink', sector: 0 });
|
||||
assert.deepEqual(click({ ...propsFor(game, forces), includeNoField: false }), [physicalOnly.action]);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('changing movement modes can retain the same selected marker without altering its event or source', () => {
|
||||
const game = projected(), before = structuredClone(game);
|
||||
const range = { ...propsFor(game, { [companionSource]: 1 }), mode: 'range' as const };
|
||||
const first = click(range);
|
||||
assert.equal(first.length, 1);
|
||||
const gather = click({ ...range, mode: 'gather', forces: { [companionSource]: 1, [secondSource]: 1 } });
|
||||
assert.deepEqual(gather, [expected(game, { [companionSource]: 1, [secondSource]: 1 })]);
|
||||
assert.deepEqual(click(range), first);
|
||||
assert.equal(gather[0].noField, first[0].noField);
|
||||
assert.equal(gather[0].event, first[0].event);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('gather requires exactly two territories including the marker and rejects missing or third origins', () => {
|
||||
const game = projected();
|
||||
const invalid: Record<string, number>[] = [{}, { [companionSource]: 1 }, { [secondSource]: 0 },
|
||||
{ [secondSource]: 1, [thirdSource]: 1 }, { [companionSource]: 1, [secondSource]: 1, [thirdSource]: 1 }];
|
||||
for (const forces of invalid) {
|
||||
const draft = planetologistMoveDraft(game, 'gather', 'polar_sink', 0, forces, {}, true);
|
||||
assert.equal(draft.action, null);
|
||||
assert.match(draft.blocked!, /exactly two different territories/);
|
||||
assert.deepEqual(click(propsFor(game, forces)), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('each origin keeps ordinary range and a distance-two marker receives no gather range bonus', () => {
|
||||
const game = projected(), me = game.players[0];
|
||||
for (const source of [markerSource, secondSource]) {
|
||||
assert.equal(groundMoveRouteDistance(game, me, source, 'polar_sink:0'), 1);
|
||||
assert.equal(botGroundMoveAllowed(game, me, source, 'polar_sink:0', 0, 'gather'), true);
|
||||
}
|
||||
assert.equal(groundMoveRouteDistance(game, me, thirdSource, 'polar_sink:0'), 2);
|
||||
assert.equal(botGroundMoveAllowed(game, me, thirdSource, 'polar_sink:0', 0, 'range'), true);
|
||||
assert.equal(botGroundMoveAllowed(game, me, thirdSource, 'polar_sink:0', 0, 'gather'), false);
|
||||
assert.deepEqual(click(propsFor(game, { [thirdSource]: 1 })), []);
|
||||
game.richeseNoField!.private!.deployed!.location = splitLocation(thirdSource);
|
||||
game.richeseNoField!.public.deployed!.location = splitLocation(thirdSource);
|
||||
const gather = planetologistMoveDraft(game, 'gather', 'polar_sink', 0, { [secondSource]: 1 }, {}, true);
|
||||
assert.match(gather.blocked!, /normal range/);
|
||||
assert.deepEqual(click(propsFor(game)), []);
|
||||
assert.equal(click({ ...propsFor(game, {}), mode: 'range' }).length, 1);
|
||||
});
|
||||
|
||||
void test('whole available force counts exact locations and selected elite counts guard gather submission', () => {
|
||||
const game = projected();
|
||||
const invalid: unknown[] = [null, undefined, [], { [secondSource]: -1 }, { [secondSource]: 0.5 },
|
||||
{ [secondSource]: Number.NaN }, { [secondSource]: Infinity }, { [secondSource]: '1' }, { [secondSource]: 4 },
|
||||
{ 'wind_pass_north:17:extra': 1 }, { 'wind_pass_north:16': 1 }, { 'homeworld:richese': 1 }];
|
||||
for (const forces of invalid)
|
||||
assert.deepEqual(click({ ...propsFor(game), forces: forces as Record<string, number> }), []);
|
||||
assert.deepEqual(click({ ...propsFor(game), elites: { [secondSource]: 1 } }), []);
|
||||
for (const sector of [-1, 1, 0.5, Number.NaN])
|
||||
assert.deepEqual(click({ ...propsFor(game), sector }), []);
|
||||
const storm = projected(); storm.storm = 14;
|
||||
assert.deepEqual(click(propsFor(storm)), []);
|
||||
});
|
||||
|
||||
void test('empty optional reason cannot bypass phase response automatic ownership or remaining-movement guards', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.phase = 6; },
|
||||
game => { game.active = 'a'; },
|
||||
game => { game.response = {} as NonNullable<GameView['response']>; },
|
||||
game => { game.decision = { kind: 'mobileStronghold', player: 'a', placement: false }; },
|
||||
game => { game.phaseOpening = { passed: [] }; },
|
||||
game => { game.truthtrance = {} as NonNullable<GameView['truthtrance']>; },
|
||||
game => { game.automaticContinuationPending = true; },
|
||||
game => { game.players[0].autopilot = 'Easy'; },
|
||||
game => { game.players[0].moved = 1; },
|
||||
game => { game.ornithopter = { active: { event: 'controlled-flight' } } as NonNullable<GameView['ornithopter']>; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(); change(game);
|
||||
const props = { ...propsFor(game), unavailableReason: '' };
|
||||
const draft = planetologistMoveDraft(game, props.mode, props.destination, props.sector, props.forces, props.elites, true);
|
||||
assert.ok(planetologistMovementBlocked(game, draft, ''));
|
||||
assert.deepEqual(click(props), []);
|
||||
assert.match(button(renderToStaticMarkup(createElement(PlanetologistMovementAction, props))), /\sdisabled=/);
|
||||
}
|
||||
const game = projected();
|
||||
assert.deepEqual(click({ ...propsFor(game), unavailableReason: '' }), [expected(game, { [secondSource]: 1 })]);
|
||||
assert.deepEqual(click({ ...propsFor(game), busy: true }), []);
|
||||
assert.deepEqual(click({ ...propsFor(game), unavailableReason: 'Complete the promised shipment before moving.' }), []);
|
||||
});
|
||||
|
||||
void test('current trainer and owned current marker remain required for gather', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.leaderSkills = null; },
|
||||
game => { game.players[0].leaders[0].dead = true; },
|
||||
game => { game.players[0].leaders[0].capturedBy = 'a'; },
|
||||
game => { game.leaderSkills!.assignments[0].controller = 'a'; },
|
||||
game => { game.richeseNoField!.private = null; },
|
||||
game => { game.richeseNoField!.owner = 'a'; },
|
||||
game => { game.richeseNoField!.private!.deployed!.controller = 'a'; },
|
||||
game => { game.richeseNoField!.private!.lastShipped = 'stale-token'; },
|
||||
game => { game.me = 'a'; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(); change(game);
|
||||
assert.deepEqual(click(propsFor(game)), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('stale marker events sources trainers and current force availability guard the already-rendered callback', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.richeseNoField!.event = 'new-gather-event'; },
|
||||
game => {
|
||||
game.richeseNoField!.private!.deployed!.tokenId = 'gather-three';
|
||||
game.richeseNoField!.private!.lastShipped = 'gather-three';
|
||||
},
|
||||
game => {
|
||||
game.richeseNoField!.private!.deployed!.location = splitLocation(companionSource);
|
||||
game.richeseNoField!.public.deployed!.location = splitLocation(companionSource);
|
||||
},
|
||||
game => { game.players[0].leaders[0].dead = true; },
|
||||
game => { game.players[0].forces[secondSource] = 0; },
|
||||
game => { game.automaticContinuationPending = true; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(), actions: Action[] = [];
|
||||
const leaf = PlanetologistMovementAction({ ...propsFor(game), act(action) { actions.push(action); } });
|
||||
assert.equal(leaf.props.disabled, false);
|
||||
change(game); leaf.props.onClick();
|
||||
assert.deepEqual(actions, []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Homeworld marker movement denial remains independent of ordinary physical gather', () => {
|
||||
const game = projected(), reason = 'The occupied Homeworld currently prevents No-Field movement.';
|
||||
game.homeworldMobility.noFieldMovementBlocked = reason;
|
||||
const draft = planetologistMoveDraft(game, 'gather', 'polar_sink', 0, { [secondSource]: 1 }, {}, true);
|
||||
assert.equal(draft.blocked, reason);
|
||||
assert.deepEqual(click(propsFor(game)), []);
|
||||
const forces = { [companionSource]: 1, [secondSource]: 1 };
|
||||
assert.deepEqual(click({ ...propsFor(game, forces), includeNoField: false }), [
|
||||
{ type: 'move', planetologist: 'gather', forces, territory: 'polar_sink', sector: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
void test('gather controls never inspect denominations rival hands raw token events or reserve counts', () => {
|
||||
const game = projected();
|
||||
for (const token of game.richeseNoField!.private!.tokens)
|
||||
Object.defineProperty(token, 'value', { get() { throw Error('Gather read a concealed denomination'); } });
|
||||
for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'reserves', 'noFieldEvent'])
|
||||
Object.defineProperty(player, field, { get() { throw Error(`Gather read unrelated ${player.id}.${field}`); } });
|
||||
assert.match(markup(game), /Provisional Planetologist No-Field gather/);
|
||||
assert.deepEqual(click(propsFor(game)), [expected(game, { [secondSource]: 1 })]);
|
||||
const rival = projected(); rival.richeseNoField!.owner = 'a';
|
||||
Object.defineProperty(rival.richeseNoField!, 'private', { get() { throw Error('Gather read a foreign token identity'); } });
|
||||
assert.deepEqual(click(propsFor(rival)), []);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { location, splitLocation, territory } from '../game/board';
|
||||
import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { planetologistLeader } from '../game/planetologist-movement';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import {
|
||||
actPlanetologistNoFieldGather as act, createPlanetologistNoFieldGatherFixture as fixture,
|
||||
finishPlanetologistNoFieldGather as finish, movePlanetologistNoFieldGather as move,
|
||||
planetologistNoFieldGatherInventory as inventory, planetologistNoFieldGatherPhysical as physical,
|
||||
planetologistNoFieldGatherPlayer as player, type PlanetologistNoFieldGatherFixture,
|
||||
} from './fixture-planetologist-no-field-gather';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function source(f: PlanetologistNoFieldGatherFixture) {
|
||||
assert.equal(f.game.status, 'playing'); assert.equal(f.game.advanced, f.advanced);
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.actor);
|
||||
assert.deepEqual(f.game.expansions, ['choam']);
|
||||
assert.deepEqual(f.game.players.map(seat => seat.faction), ['richese', 'atreides', 'emperor']);
|
||||
assert.ok(f.game.leaderSkills);
|
||||
assert.ok(!f.game.homeworlds && !f.game.nexusCards && !f.game.techTokens &&
|
||||
!f.game.strongholdCards && !f.game.discoveryEnabled && !f.game.ecazTreachery);
|
||||
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.response ?? null, null);
|
||||
assert.equal(f.game.decision ?? null, null); assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'leaderSkills' && step.action.enabled === true));
|
||||
assert.ok(f.actions.some(step => step.action.type === 'start'));
|
||||
assert.ok(f.actions.some(step => step.actor === f.actor && step.action.type === 'leaderSkill' &&
|
||||
step.action.skill === 'planetologist' && step.action.leader === f.trainer));
|
||||
assert.equal(planetologistLeader(f.game, f.actor), f.trainer);
|
||||
assert.equal(player(f.game, f.actor).shipped, true); assert.equal(player(f.game, f.actor).moved, 0);
|
||||
assert.equal(f.physicalShipment.before.turn, 1);
|
||||
assert.equal(f.physicalShipment.step.action.type, 'ship');
|
||||
assert.equal(f.physicalShipment.step.action.amount, f.amount);
|
||||
assert.equal(player(f.physicalShipment.before, f.actor).spice - player(f.physicalShipment.after, f.actor).spice, f.amount * 2);
|
||||
assert.equal(player(f.physicalShipment.before, f.actor).reserves - player(f.physicalShipment.after, f.actor).reserves, f.amount);
|
||||
assert.equal(player(f.game, f.actor).forces[f.physicalSource], 1);
|
||||
assert.equal(player(f.game, f.actor).forces[f.markerSource] ?? 0, Number(f.withMarkerCompanion));
|
||||
assert.notEqual(f.sourceLocation.territory, f.physicalSourceLocation.territory);
|
||||
assert.equal(territory(f.sourceLocation.territory).type, 'rock');
|
||||
assert.equal(territory(f.physicalSourceLocation.territory).type, 'rock');
|
||||
assert.equal(territory(f.target.territory).type, 'rock');
|
||||
assert.equal(f.beforeShipment.turn, 2);
|
||||
assert.equal(f.shipmentStep.action.type, 'ship'); assert.equal(f.shipmentStep.action.noField, f.token.id);
|
||||
assert.equal(player(f.beforeShipment, f.actor).spice - player(f.game, f.actor).spice, 2);
|
||||
assert.equal(player(f.beforeShipment, f.actor).reserves, player(f.game, f.actor).reserves);
|
||||
assert.equal(player(f.game, f.actor).reserves, 20 - f.amount);
|
||||
assert.equal(player(f.game, f.actor).noField!.deployed!.tokenId, f.token.id);
|
||||
assert.equal(player(f.game, f.actor).noField!.lastShipped, f.token.id);
|
||||
assert.deepEqual(player(f.game, f.actor).noField!.deployed!.location, f.sourceLocation);
|
||||
assert.equal(player(f.game, f.actor).noField!.tokens.find(token => token.id === f.token.id)!.value, f.value);
|
||||
assert.equal(Object.hasOwn(f.action, 'origins'), false, 'The server normalizes marker-first origins from actual pieces');
|
||||
const view = viewGame(f.game, f.actor), me = view.players.find(seat => seat.id === f.actor)!;
|
||||
for (const from of [f.markerSource, f.physicalSource]) {
|
||||
assert.equal(groundMoveRouteDistance(view, me, from, f.targetLocation), 1);
|
||||
assert.equal(botGroundMoveAllowed(view, me, from, f.targetLocation, 0, 'gather'), true);
|
||||
assert.equal(botGroundMoveAllowed(view, me, from, f.tooFarLocation, 0, 'range'), true);
|
||||
}
|
||||
assert.ok([f.markerSource, f.physicalSource].some(from =>
|
||||
groundMoveRouteDistance(view, me, from, f.tooFarLocation) === 2 &&
|
||||
!botGroundMoveAllowed(view, me, from, f.tooFarLocation, 0, 'gather')));
|
||||
if (f.withMarkerCompanion) {
|
||||
assert.ok(f.splitMovement);
|
||||
assert.equal(f.splitMovement.before.turn, 1);
|
||||
assert.equal(f.splitMovement.step.action.type, 'move');
|
||||
assert.equal(f.splitMovement.step.action.planetologist, undefined);
|
||||
assert.equal(player(f.splitMovement.after, f.actor).moved, 1);
|
||||
assert.equal(player(f.splitMovement.after, f.actor).forces[f.physicalSource], 1);
|
||||
assert.equal(player(f.splitMovement.after, f.actor).forces[f.markerSource], 1);
|
||||
assert.equal(player(f.afterFirstPreparation, f.actor).spice, 1);
|
||||
assert.ok(f.charity);
|
||||
assert.equal(f.charity.before.turn, 2); assert.equal(f.charity.before.phase, 2);
|
||||
assert.equal(f.charity.step.actor, f.actor); assert.equal(f.charity.step.action.type, 'charity');
|
||||
assert.equal(player(f.charity.before, f.actor).spice, 1);
|
||||
assert.equal(player(f.charity.after, f.actor).spice, 2);
|
||||
assert.deepEqual(player(f.charity.before, f.actor).forces, player(f.charity.after, f.actor).forces);
|
||||
assert.equal(player(f.beforeShipment, f.actor).spice, 2, 'The played second-turn charity funds the marker shipment');
|
||||
} else { assert.equal(f.splitMovement, null); assert.equal(f.charity, null); }
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function moved(f: PlanetologistNoFieldGatherFixture, game: Game) {
|
||||
const before = player(f.game, f.actor), after = player(game, f.actor);
|
||||
assert.deepEqual(after.noField!.tokens, f.originalNoField.tokens);
|
||||
assert.equal(after.noField!.lastShipped, f.originalNoField.lastShipped);
|
||||
assert.deepEqual(after.noField!.deployed, { ...f.originalNoField.deployed!, location: f.target });
|
||||
assert.notEqual(after.noFieldEvent, f.originalEvent);
|
||||
assert.equal(after.moved, before.moved + 1); assert.equal(after.shipped, true);
|
||||
assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.forces[f.physicalSource] ?? 0, 0); assert.equal(after.forces[f.markerSource] ?? 0, 0);
|
||||
assert.equal(after.forces[f.targetLocation], f.amount);
|
||||
assert.equal(presenceAt(after, f.target.territory), f.amount + 1,
|
||||
'Only the original selected counters and one concealed marker gather; no denomination materializes');
|
||||
assert.equal(planetologistLeader(game, f.actor), f.trainer);
|
||||
assert.deepEqual(game.leaderSkills, f.game.leaderSkills);
|
||||
assert.deepEqual(after.leaders, before.leaders); assert.deepEqual(after.hand, before.hand);
|
||||
const observed = viewGame(game, f.observer).richeseNoField!;
|
||||
assert.equal(observed.private, null);
|
||||
assert.deepEqual(observed.public.deployed, { controller: f.actor, location: f.target, effectiveForces: 1 });
|
||||
const chronicle = game.log.slice(f.game.log.length).find(entry => entry.text.includes('moved a concealed No-Field'));
|
||||
assert.ok(chronicle);
|
||||
assert.ok(chronicle.text.includes(`from ${territory(f.sourceLocation.territory).name} and ${territory(f.physicalSourceLocation.territory).name} to ${territory(f.target.territory).name}`),
|
||||
'The real chronicle identifies both normalized origins in their original marker-first order');
|
||||
assert.ok(chronicle.text.endsWith('Its value remains concealed.'));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) for (const withMarkerCompanion of [false, true]) {
|
||||
void test(`public ${advanced ? 'Advanced' : 'Basic'} concealed${value} ${withMarkerCompanion ? 'with an earned marker companion' : 'and a remote physical counter'} gathers two original origins once`, () => {
|
||||
const f = fixture({ advanced, value, withMarkerCompanion }); source(f);
|
||||
rejected(f.game, f.actor, f.tooFarAction);
|
||||
rejected(f.game, f.actor, { ...f.action, planetologist: 'range' });
|
||||
const ordinary: Action = { ...f.action }; delete ordinary.planetologist;
|
||||
rejected(f.game, f.actor, ordinary);
|
||||
const before = structuredClone(f.game);
|
||||
let game = move(f);
|
||||
assert.deepEqual(f.game, before); moved(f, game);
|
||||
const wallets = game.players.map(seat => seat.spice);
|
||||
assert.deepEqual(wallets, f.game.players.map(seat => seat.spice));
|
||||
rejected(game, f.actor, f.action);
|
||||
const relocated = structuredClone(player(game, f.actor).noField), event = player(game, f.actor).noFieldEvent;
|
||||
const continuation: { actor: string; action: Action }[] = [];
|
||||
game = finish(f, game, continuation);
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 7);
|
||||
assert.ok(continuation.some(step => step.actor === f.actor && step.action.type === 'endMovement'));
|
||||
moved(f, game);
|
||||
assert.deepEqual(player(game, f.actor).noField, relocated);
|
||||
assert.equal(player(game, f.actor).noFieldEvent, event);
|
||||
});
|
||||
}
|
||||
|
||||
void test('native marker gather rejects stale, foreign and malformed origin selections before moving either source', () => {
|
||||
const f = fixture({ withMarkerCompanion: true }); source(f);
|
||||
rejected(f.game, f.opponent, f.action);
|
||||
rejected(f.game, f.actor, { ...f.action, event: 'stale-native-marker-event' });
|
||||
const other = player(f.game, f.actor).noField!.tokens.find(token => token.id !== f.token.id)!;
|
||||
rejected(f.game, f.actor, { ...f.action, noField: other.id });
|
||||
rejected(f.game, f.actor, { ...f.action, forces: {} });
|
||||
rejected(f.game, f.actor, { ...f.action, forces: { [f.markerSource]: 1 } });
|
||||
const physicalOnly: Action = { ...f.action, forces: { [f.physicalSource]: 1 } };
|
||||
delete physicalOnly.noField; delete physicalOnly.event;
|
||||
rejected(f.game, f.actor, physicalOnly);
|
||||
rejected(f.game, f.actor, { ...f.action, origins: [f.sourceLocation.territory, f.physicalSourceLocation.territory] });
|
||||
rejected(f.game, f.actor, { ...f.action, forces: { ...f.forces, 'arrakeen:10': 1 } });
|
||||
rejected(f.game, f.actor, { ...f.action, discoveryFlight: { event: 'foreign-flight' } });
|
||||
rejected(f.game, f.actor, { ...f.action, ornithopterEvent: 'foreign-card-movement' });
|
||||
assert.deepEqual(physical(f.game), f.originalPhysical);
|
||||
assert.deepEqual(player(f.game, f.actor).noField, f.originalNoField);
|
||||
assert.equal(player(f.game, f.actor).noFieldEvent, f.originalEvent);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('controlled eligibility and third-origin supplements cannot bypass the original gather bounds', () => {
|
||||
const f = fixture(); source(f);
|
||||
// These labelled controlled inputs do not claim played death/capture or a
|
||||
// third paid shipment. All twelve native positive sources above are unstaged.
|
||||
for (const state of ['dead', 'captured'] as const) {
|
||||
const controlled = structuredClone(f.game), leader = player(controlled, f.actor).leaders.find(disc => disc.id === f.trainer)!;
|
||||
if (state === 'dead') leader.dead = true;
|
||||
else leader.capturedBy = f.observer;
|
||||
assert.equal(planetologistLeader(controlled, f.actor), null);
|
||||
rejected(controlled, f.actor, f.action);
|
||||
}
|
||||
const three = structuredClone(f.game), owner = player(three, f.actor);
|
||||
owner.reserves--; owner.forces[f.tooFarLocation] = 1;
|
||||
assert.equal(owner.reserves + owner.tanks + Object.values(owner.forces).reduce((sum, count) => sum + count, 0), 20);
|
||||
rejected(three, f.actor, { ...f.action, forces: { ...f.forces, [f.tooFarLocation]: 1 } });
|
||||
assert.deepEqual(physical(f.game), f.originalPhysical);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles offer a legal original marker plus remote-counter gather', () => {
|
||||
for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) {
|
||||
const f = fixture({ advanced, value }); source(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(f.game, f.actor);
|
||||
view.players.find(seat => seat.id === f.actor)!.bot = difficulty;
|
||||
const candidates = botActions(view).filter(action => action.type === 'move' &&
|
||||
action.planetologist === 'gather' && action.noField === f.token.id);
|
||||
assert.ok(candidates.length, `${difficulty} offers an actual concealed${value} two-origin gather`);
|
||||
const action = candidates[0];
|
||||
assert.equal(typeof action.territory, 'string'); assert.equal(typeof action.sector, 'number');
|
||||
const destination = { territory: action.territory as string, sector: action.sector as number };
|
||||
const destinationLocation = location(destination.territory, destination.sector);
|
||||
assert.deepEqual(action.forces, { [f.physicalSource]: 1 });
|
||||
assert.equal(Object.hasOwn(action, 'origins'), false); assert.equal(action.event, f.originalEvent);
|
||||
const me = view.players.find(seat => seat.id === f.actor)!;
|
||||
for (const from of [f.markerSource, f.physicalSource])
|
||||
assert.equal(botGroundMoveAllowed(view, me, from, destinationLocation, 0, 'gather'), true);
|
||||
const game = act(f.game, f.actor, action), after = player(game, f.actor);
|
||||
assert.equal(after.noField!.deployed!.tokenId, f.token.id);
|
||||
assert.deepEqual(after.noField!.deployed!.location, destination);
|
||||
assert.equal(after.noField!.lastShipped, f.token.id);
|
||||
assert.equal(after.reserves, player(f.game, f.actor).reserves);
|
||||
assert.equal(after.spice, player(f.game, f.actor).spice);
|
||||
assert.equal(after.forces[f.physicalSource] ?? 0, 0);
|
||||
assert.equal(after.forces[destinationLocation], 1);
|
||||
assert.equal(after.moved, 1);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.notEqual(splitLocation(f.physicalSource).territory, f.sourceLocation.territory);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user