Honor allied Fremen victory and prevent deferred shipment deadlocks
Reciprocal allied Ecaz and Fremen with fighters in Sietch Tabr now satisfy that part of the Fremen final-turn victory, as explicitly clarified by the GF9 Ecaz/Moritani p15 FAQ. One public board quote drives automatic victory and the table guide; the chronicle explains the result. Other blockers, ordinary stronghold/BG prediction precedence, Guild fallback, advisor posture and concealed marker privacy remain intact. Habbanya symmetry and full mode/publication acceptance remain unfinished. A genuine Advanced sample also exposed a pre-existing accepted Guild shipment which could never finish: allowance opened BG accompaniment and then hit the Ambassador arrival guard. Prior HEAD replay reproduced the exact 90-action failure. Fresh shipment preflight now rejects unsupported arrival combinations before accepting their pending windows. A valid legacy ordinary Guild allowance can return its unspent declaration to choose another destination; special parent/card receipts retain their separate gates. The change does not invent simultaneous reaction order or refund spent cards. Independent rules/privacy/persistence review and focused engine/controls tests pass. Seventeen Fremen/Ecaz tests include all four profiles and authenticated final readiness/restart/concurrent writes. Shipment regressions cover pre-declaration immutability, supported arrival, malformed saved declarations and authenticated unspent return/replacement/reconnect/concurrency. All 4716 offline tests, types, lint, production build and 43 HTTP tests passed against unchanged source. The original fixed seeds were repeated after repair and the exact failed snapshot resumed with documented new continuation seed 202609147: resume-advanced-202609147: 842 accepted, 109 guarded rejections, 22 periodic JSON restores; genuine-basic-202609145: 563 accepted, 14 guarded rejections, 15 periodic JSON restores; genuine-advanced-202609146: 806 accepted, 81 guarded rejections, 21 periodic JSON restores. All three finish and preserve physical custody/private views. These samples do not establish naturally reached allied Fremen special wins; a separate conserved browser final-turn fixture verifies the actual Ready-to-win transition, public guide/log, private hand and pending/finished refresh. All 835 opening games and original seat/recovery records remain unchanged (902 after checks). All 869 rooms and records present after the reported outage also remain unchanged; database integrity and foreign keys pass. The healthy server and finished browser seat recovered without restart, reset or recurring automation. Full rules coverage and release gates remain open work. Verified source tree: 1d9fcf30b54132f66be5f994f410dec6ec019683ee66b24fd4efb7a6aa7f7bbd
This commit is contained in:
1 parent
58ae84f8e2
commit
9ca17a66a0
19 files changed
+1333
-31
No files matched your search
@@ -5439,7 +5439,7 @@ export function GameTable({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<VictoryProgress progress={g.victoryProgress} players={g.players} />
|
||||
<VictoryProgress progress={g.victoryProgress} players={g.players} fremen={g.fremenVictory} />
|
||||
{g.strongholdCards && (
|
||||
<details className="m-4 rounded-xl border border-[#a88b60] p-4">
|
||||
<summary className="cursor-pointer text-lg">
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { faction } from '@/game/catalog';
|
||||
import { territory } from '@/game/board';
|
||||
import type { StrongholdProgress } from '@/game/victory-progress';
|
||||
import type { FremenVictoryProgress } from '@/game/fremen-victory';
|
||||
|
||||
export function VictoryProgress({
|
||||
progress,
|
||||
players,
|
||||
fremen,
|
||||
}: {
|
||||
progress: readonly StrongholdProgress[];
|
||||
players: readonly { id: string; faction: string }[];
|
||||
fremen?: FremenVictoryProgress | null;
|
||||
}) {
|
||||
if (!progress.length) return null;
|
||||
const rows = progress.filter((row) => row.player === row.members[0]);
|
||||
@@ -66,6 +69,27 @@ export function VictoryProgress({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{fremen && (
|
||||
<section className="mt-4 space-y-2 rounded-lg border border-[#a88b60]/40 p-3" aria-label="Fremen final-turn victory">
|
||||
<h3>Fremen final-turn victory</h3>
|
||||
<p className="fine">At the end of turn ten, if nobody has a stronghold victory, these conditions let Fremen and its ally win before Guild. A Bene Gesserit prediction cannot replace this special victory.</p>
|
||||
<ul className="space-y-2">
|
||||
{fremen.sietches.map((site) => (
|
||||
<li key={site.territory}>
|
||||
{territory(site.territory).name}: {site.blockers.length
|
||||
? `blocked by ${site.blockers.map((id) => faction(players.find((p) => p.id === id)!.faction).name).join(', ')}`
|
||||
: site.ecazCooccupation ? 'permitted allied Ecaz and Fremen co-occupation' : 'empty or Fremen only'}.
|
||||
</li>
|
||||
))}
|
||||
<li>Tuek’s Sietch: {fremen.tueksBlockers.length
|
||||
? `blocked by ${fremen.tueksBlockers.map((id) => faction(players.find((p) => p.id === id)!.faction).name).join(', ')}`
|
||||
: 'no prohibited faction present'}.</li>
|
||||
</ul>
|
||||
<p className={fremen.qualifies ? 'notice' : 'fine'}>{fremen.qualifies
|
||||
? 'Fremen’s special conditions are met on this board. They award victory only at the final-turn check.'
|
||||
: 'Fremen’s special conditions are not met on this board.'}</p>
|
||||
</section>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
+36
-5
@@ -6,10 +6,10 @@ gated.** Prototypes do not open completion or publication gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
Latest verified checkpoint: `8906b41`, Truthtrance recorded knowledge.
|
||||
Types, lint, **4,662 offline tests**, build and **43 HTTP tests** passed.
|
||||
All 767 opening rooms and original seat/recovery records were preserved
|
||||
(801 after HTTP checks); all 768 present immediately before verification also
|
||||
Previous verified checkpoint: `58ae84f`, Moritani Assassinate Leaders preview.
|
||||
Types, lint, **4,690 offline tests**, build and **43 HTTP tests** passed.
|
||||
All 801 opening rooms and original seat/recovery records were preserved
|
||||
(835 after HTTP checks); all 802 present immediately before verification also
|
||||
remained unchanged. The healthy server was reused.
|
||||
|
||||
[Mentat](MENTAT_QUESTION.md) connects private naming/disclosure before posture
|
||||
@@ -56,7 +56,7 @@ the healthy server is reused. A new snapshot protects all 768 rooms before broad
|
||||
verification. Final checks, preservation totals and Git delivery are recorded in
|
||||
the checkpoint commit and private report; full mode/publication gates remain.
|
||||
|
||||
Current checkpoint: [Moritani Assassinate Leaders](MORITANI_ASSASSINATE_LEADERS.md)
|
||||
Completed prototype: [Moritani Assassinate Leaders](MORITANI_ASSASSINATE_LEADERS.md)
|
||||
connects a qualifying battle loss to a private reveal/continue choice, living/dead
|
||||
targets, bank bounty, public markers and automatic Mentat replacement. All four
|
||||
AI profiles and saved continuation are connected in the bounded Advanced preview.
|
||||
@@ -70,6 +70,37 @@ All 801 opening games remain unchanged after the reported outage; the healthy
|
||||
server is reused. Required broad checks, source fingerprint, final preservation
|
||||
and Git delivery are recorded in the checkpoint commit and private report.
|
||||
|
||||
Current checkpoint: [Fremen/Ecaz final-turn victory](ECAZ_VICTORY_RULES.md#fremen-final-turn-runtime-follow-up).
|
||||
The explicit Sietch Tabr exception is connected to the same public board quote
|
||||
used by automatic victory and the table guide. Reciprocal allies must both be
|
||||
present; solitary/non-allied Ecaz and third-party fighters still block. Ordinary
|
||||
stronghold/BG precedence, the other protected sietch and Tuek conditions, Guild
|
||||
fallback and private No-Field identity remain intact. Independent review and
|
||||
17 focused cases pass, including all-profile final readiness and authenticated
|
||||
SQLite restart/concurrent writes. Final-turn fixtures are staged after genuine
|
||||
setup; they do not establish naturally reached allied special wins. Habbanya
|
||||
symmetry and complete mode/expansion acceptance remain unfinished. Broad checks,
|
||||
browser/sample evidence, saved-game preservation and Git delivery are recorded
|
||||
with the checkpoint.
|
||||
|
||||
The same checkpoint repairs a pre-existing [deferred Guild shipment deadlock](DEFERRED_SHIPMENT_ARRIVAL.md)
|
||||
found after 90 accepted Advanced actions. Fresh ordinary and No-Field declarations
|
||||
preflight unsupported arrivals; an eligible saved ordinary Guild allowance returns
|
||||
unspent for another choice. Special card/parent receipts retain their own gates.
|
||||
Independent review and its 149-case union pass. The exact failed snapshot now
|
||||
finishes in 842 further accepted actions. The original Basic/Advanced seeds finish
|
||||
in 563/806 actions, with 14/81 guarded candidates; the resume has 109. All physical
|
||||
custody, 58 periodic JSON rounds and 12 terminal private views pass. The Advanced
|
||||
runs form Ecaz/Fremen alliances but do not exercise the shared-Tabr exception.
|
||||
These are bounded playability checks, not complete arrival-order acceptance.
|
||||
|
||||
After the latest reported outage, the server responds normally, all 835 opening
|
||||
rooms and original seat/recovery records remain unchanged, and integrity/foreign-key
|
||||
checks pass. A new backup protects all 869 rooms before final checks. The completed
|
||||
browser game restores its result and private seat; no restart, reset or automation
|
||||
is required. Final broad verification, preservation totals and Git delivery are
|
||||
recorded with the checkpoint.
|
||||
|
||||
## Authoritative navigation
|
||||
|
||||
- [Rules checklist](../game/reference.ts), topic `implementation-checklist` and
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Deferred shipment and arrival validation
|
||||
|
||||
14 September 2026. A genuine Advanced Ecaz/Fremen/Guild/Bene Gesserit sample
|
||||
exposed an already accepted shipment whose Guild allowance could not finish.
|
||||
This continuation repair preserves the existing unfinished arrival-order gates.
|
||||
|
||||
## Reproduced failure
|
||||
|
||||
The fixed seed `202609146` stopped on turn one after 90 accepted actions. Guild
|
||||
declared four forces to Carthag, sector 11, where an Ecaz Emperor Ambassador was
|
||||
placed. Allowing that shipment created a Bene Gesserit accompaniment choice,
|
||||
then the Ambassador overlap guard rejected the arrival. The rejected action was
|
||||
immutable, but the earlier accepted declaration left the table unable to proceed.
|
||||
|
||||
An isolated archive of prior checkpoint `58ae84f` replayed the same setup seed
|
||||
and all 90 accepted actions. The resulting player state and pending shipment
|
||||
matched, and Guild allowance failed with the same guard. The defect predates the
|
||||
Fremen/Ecaz victory follow-up. Private traces and failed snapshots are retained;
|
||||
the failure was not replaced with a successful seed.
|
||||
|
||||
The Basic sample with seed `202609145` finished on turn seven in 563 accepted
|
||||
actions, using alternatives after 14 guarded shipment candidates. The initial
|
||||
Advanced sample did not finish. Neither formed the Fremen/Ecaz alliance or
|
||||
exercised its final-turn exception. Staged endgame checks are separate evidence.
|
||||
|
||||
## Rules boundary
|
||||
|
||||
The [deferred movement contract](DEFERRED_MOVEMENT_ARRIVAL.md) records the
|
||||
unresolved ordering of simultaneous Ambassador, Terror and Bene Gesserit arrival
|
||||
effects. Shipment prevention precedes payment and arrival; an allowed shipment
|
||||
still needs a supported continuation. Refusing an unfinished combination in this
|
||||
development build does not mean the physical rules prohibit that shipment.
|
||||
|
||||
This work does not choose an Ambassador/Terror priority, suppress an earned
|
||||
Bene Gesserit choice, or open Advanced, expansion or publication gates. The
|
||||
existing [Ambassador](ECAZ_AMBASSADORS_RULES.md),
|
||||
[Terror](MORITANI_TERROR_RULES.md) and
|
||||
[Guild payment](GUILD_SHIPMENT_PAYMENTS.md) contracts retain their boundaries.
|
||||
|
||||
## Continuation contract
|
||||
|
||||
Ordinary shipment now quotes the resulting arrival before opening Guild's
|
||||
interception decision and again before settlement. The adapter shares the
|
||||
existing arrival guard with movement and includes pending controls, Guild income,
|
||||
Bene Gesserit intrusion/accompaniment and Fremen storm protection. It does not
|
||||
spend resources or inspect a concealed No-Field value to choose reaction order.
|
||||
Own No-Field shipments also preflight before their earlier cancellation window,
|
||||
which otherwise bypasses the common shipment offer.
|
||||
Ambassador-generated shipments retain their distinct validated parent and return
|
||||
path.
|
||||
|
||||
An already saved ordinary Guild declaration may return to its shipper when
|
||||
explicit allowance encounters this typed unsupported-arrival error. Its original
|
||||
declaration, ownership, price, force pools and funding must still validate first.
|
||||
Every excluded special-shipment marker must be absent, rather than merely falsy;
|
||||
forged, null and empty receipt fields cannot be discarded as an ordinary return.
|
||||
Only the pending shipment and decision are released; the log explains the
|
||||
unfinished overlap, and the shipper retains its shipment and movement choices.
|
||||
Unrelated errors and corrupted declarations still reject without writes.
|
||||
|
||||
Legacy declarations tied to an Ambassador, No-Field, Smuggler or spent Nexus receipt do
|
||||
not use this return path: their custody and parent continuation need separate
|
||||
handling. The repair does not refund or rewrite those receipts. Fresh ordinary
|
||||
declarations using the shared offer path receive preflight before the Guild
|
||||
decision can be accepted. This is bounded recovery, not complete arrival-order
|
||||
implementation or a universal repair for every older pending shipment.
|
||||
|
||||
## Focused verification
|
||||
|
||||
Six engine regressions cover the original declaration, a supported allowance,
|
||||
legacy return and replacement, own No-Field preflight, stale prices and forged
|
||||
special markers. Three authenticated SQLite regressions cover pending and
|
||||
returned private-seat restoration, exact custody, replacement arrival, wrong
|
||||
actor/choice, stale/duplicate requests, corrupted declarations and concurrent
|
||||
allowances committing one return. Fixtures use genuine faction setup followed
|
||||
by an explicitly staged arrival topology; the historical sample retains its
|
||||
separate genuine-game provenance.
|
||||
|
||||
Independent review found and resolved the earlier No-Field window bypass and
|
||||
falsy special-marker recovery checks. Its focused union passed 149 cases, and
|
||||
targeted lint passed. Final broad checks, source correspondence, full samples
|
||||
and saved-game preservation are recorded with the checkpoint.
|
||||
|
||||
## Integrated samples after repair
|
||||
|
||||
The same Basic and Advanced setup seeds and four AI profiles were repeated after
|
||||
the code review. The exact failed Advanced snapshot was also resumed without
|
||||
editing its state; its first Guild allowance now returns the uncommitted shipment.
|
||||
The continuation uses seed `202609147`, a new random stream because the original
|
||||
cursor was not saved. Its input hash and provenance are retained privately.
|
||||
|
||||
| Run | Accepted actions | Guarded candidates | Periodic JSON rounds | Result |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| Exact saved continuation | 842 additional | 109 (100 shipment, 9 movement) | 22 | Turn 10, Guild |
|
||||
| Original Basic seed `202609145` | 563 | 14 shipment | 15 | Turn 7, Guild |
|
||||
| Original Advanced seed `202609146` | 806 | 81 (74 shipment, 7 movement) | 21 | Turn 9, Bene Gesserit |
|
||||
|
||||
All games finish using legal alternative candidates after the explicit development
|
||||
guards. Physical custody and all twelve terminal private views pass. All 216
|
||||
game-file hashes remain unchanged throughout the samples. Both Advanced runs
|
||||
form Ecaz/Fremen alliances but never co-occupy Sietch Tabr or exercise its special
|
||||
victory. The saved continuation ends with allied Habbanya co-occupation, correctly
|
||||
outside the supported Tabr exception. These samples demonstrate bounded repair
|
||||
and continuation, not full rules-combination acceptance or AI strength calibration.
|
||||
@@ -1,5 +1,9 @@
|
||||
# Ecaz victory implementation contract
|
||||
|
||||
The [14 September runtime follow-up](#fremen-final-turn-runtime-follow-up) connects
|
||||
the explicit Fremen/Ecaz Sietch Tabr exception. The original audit and its
|
||||
unimplemented Habbanya inference remain below as source context.
|
||||
|
||||
Primary-source review, 2026-09-07. This separates the explicit three-stronghold rule from its ordinary control and prediction consequences. No combat-rounding decision is needed for a victory predicate operating on an already-settled board. The unrelated rounding conflict remains as corrected in [Ecaz Occupy rules](ECAZ_OCCUPY_RULES.md).
|
||||
|
||||
## Ordinary victory and the Ecaz exception
|
||||
@@ -48,6 +52,47 @@ The Habbanya symmetry is well-supported by the common general rule but is not an
|
||||
|
||||
Keep the other Fremen checks unchanged: no earlier stronghold winner; final turn; excluded factions in Tuek's Sietch. Advanced Richese is added to Tuek's prohibited list by E2 p.6. Effective No-Field presence matters there too. If the Fremen special condition succeeds, its ally shares that special win; the Guild fallback only follows an unsuccessful earlier victory condition. [Base pp.16,19](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf), [E2 p.6](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=6)
|
||||
|
||||
## Fremen final-turn runtime follow-up
|
||||
|
||||
14 September 2026. The publisher's indexed p.15 FAQ was freshly checked and agrees
|
||||
with the original source record. `game/fremen-victory.ts` now supplies one shared,
|
||||
public settled-board description to the authoritative victory quote and player
|
||||
guide. It applies the explicit Sietch Tabr exception only when reciprocal Ecaz
|
||||
and Fremen allies both have fighters there. It preserves third-faction blockers,
|
||||
mandatory advisor conversion, accompanied advisors and concealed No-Field presence
|
||||
without reading hidden marker value. Habbanya symmetry remains outside this
|
||||
implemented exception; that is an implementation limit, not a new printed ban.
|
||||
|
||||
The final-turn quote retains ordinary stronghold victory and applicable prediction
|
||||
first, then the Fremen special condition, then existing fallback behavior. A
|
||||
successful Fremen special win includes its ally and cannot be intercepted by a
|
||||
Bene Gesserit prediction. No new action, acknowledgement, saved decision or schema
|
||||
is added. The public guide names territory blockers before the check; a successful
|
||||
special victory receives an explanatory chronicle entry.
|
||||
|
||||
Independent rules/privacy review found no blocker. Seventeen focused cases cover
|
||||
Basic/Advanced outcome, earlier-turn and normal-winner precedence, all four AI
|
||||
profiles, absent/nonallied/solitary/third-party counterexamples, Habbanya scope,
|
||||
advisor and marker handling, identical public controls, JSON and authenticated
|
||||
SQLite restoration. Competing final readiness commits once; finished or stale
|
||||
actions cannot write again. Cards, Traitors, forces, elites and seat records remain
|
||||
unchanged through the final transition. The endgame fixture explicitly stages a
|
||||
conserved final-turn board and alliance after genuine setup, including a BG
|
||||
prediction of Fremen for turn ten; it does not claim natural arrival at that board.
|
||||
|
||||
The checkpoint records browser and complete-sample observations, required broad
|
||||
checks, source fingerprint, live saved-game preservation and verified Git delivery.
|
||||
Full faction, optional-module and publication gates remain unchanged.
|
||||
|
||||
The expanded victory regression union passes 83 cases. The browser exercise uses
|
||||
a fresh four-seat lobby and the same explicitly staged genuine-setup final board.
|
||||
The public guide displays the permitted Tabr co-occupation, the existing Ready
|
||||
action reaches the allied win, and the chronicle gives its reason. Pending and
|
||||
finished refresh preserve the human seat, private hand and exact physical state.
|
||||
Independent read-only database comparison confirms unchanged spice, cards,
|
||||
Traitors, leaders, forces and elites; one victory log is present. This remains a
|
||||
targeted endgame exercise, not a ten-turn human game.
|
||||
|
||||
## Bene Gesserit prediction and implementation order
|
||||
|
||||
An Ecaz three-co-occupied win is a stronghold victory with a modified threshold. BG prediction applies when the predicted faction is either winning ally on the predicted turn, including BG's own ally; BG then wins alone. Do not place Ecaz's new predicate after the existing prediction check. Base p.18 excludes the distinct Fremen/Guild special endgame wins from prediction; this Ecaz rule is not one of those named exceptions. [Base p.18](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=18)
|
||||
|
||||
@@ -1,5 +1,54 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 14 September 2026 — Fremen/Ecaz final-turn victory and deferred shipment repair
|
||||
|
||||
The [explicit Sietch Tabr FAQ](ECAZ_VICTORY_RULES.md#fremen-final-turn-runtime-follow-up)
|
||||
now reaches automatic victory resolution: reciprocal Ecaz/Fremen allies with
|
||||
fighters there may satisfy the Fremen final-turn condition. The same public quote
|
||||
drives territory/blocker guidance; the chronicle explains a successful special
|
||||
win. Solitary/nonallied Ecaz, unrelated fighters and the unsupported Habbanya
|
||||
extension retain their existing exclusions. Effective advisor and concealed-marker
|
||||
presence, ordinary stronghold/prediction precedence, Guild fallback and existing
|
||||
no-Guild behavior are preserved. This adds no mandatory confirmation or schema.
|
||||
|
||||
Independent source/privacy review found no blocker. Seventeen focused cases pass,
|
||||
including Basic/Advanced outcomes, all four AI profiles, JSON/private views and
|
||||
authenticated SQLite restoration before and after final readiness. Concurrent
|
||||
last-ready requests commit once; stale/finished requests do not write. Exact cards,
|
||||
Traitors, forces, elites and seat records are preserved. Final-turn positions are
|
||||
explicitly staged after genuine setup, including the reciprocal alliance and a
|
||||
turn-ten Fremen prediction, rather than claimed as natural full-game outcomes.
|
||||
The broader existing victory regression union passes 83 cases. A fresh browser
|
||||
room verifies the public guide, actual Ready-to-win transition, explanatory log
|
||||
and pending/finished refresh. Private read-only comparison confirms unchanged
|
||||
spice, cards, Traitors, leaders, forces, elites and seat records.
|
||||
|
||||
A genuine Advanced sample also exposed the pre-existing [Guild shipment
|
||||
deadlock](DEFERRED_SHIPMENT_ARRIVAL.md): an accepted declaration could not pass
|
||||
an Ambassador/BG arrival overlap. Prior HEAD replay reproduced all 90 actions.
|
||||
Fresh ordinary/No-Field preflight and narrow, typed saved-declaration recovery
|
||||
now avoid the trap while preserving unresolved reaction-order gates. Independent
|
||||
review resolved an early No-Field window bypass and malformed special-marker
|
||||
handling; its 149-case union passes, including three authenticated SQLite cases.
|
||||
|
||||
The exact failed snapshot finishes after 842 further accepted actions, with
|
||||
109 guarded candidates, on turn ten for Guild. The original Basic/Advanced seeds
|
||||
finish in 563/806 accepted actions with 14/81 guards, on turns seven/nine for
|
||||
Guild/BG. All 58 periodic JSON rounds, twelve terminal private views and physical
|
||||
custody checks pass against unchanged gameplay source. The Advanced runs form
|
||||
Ecaz/Fremen alliances, but neither uses shared Tabr or its special victory.
|
||||
The saved continuation uses a documented new random stream; no failed seed or
|
||||
snapshot was silently replaced. Simultaneous arrival integration remains open.
|
||||
|
||||
The latest reported outage preserved all 835 opening games and original seat
|
||||
records. All 869 current rooms are backed up before final verification, and the
|
||||
finished browser seat restores correctly. The healthy server is reused.
|
||||
|
||||
Browser and complete-sample evidence, types/lint/offline/build/HTTP results,
|
||||
source fingerprint, saved-game preservation and verified Git push are recorded
|
||||
with this checkpoint. The running server is reused; complete rules coverage,
|
||||
all combinations and publication remain gated.
|
||||
|
||||
## 14 September 2026 — Moritani Assassinate Leaders prototype and outage preservation
|
||||
|
||||
[Assassinate Leaders](MORITANI_ASSASSINATE_LEADERS.md) connects native Advanced
|
||||
|
||||
@@ -18,9 +18,11 @@ Updated 14 September 2026. This is the authoritative navigation index for existi
|
||||
| Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) |
|
||||
| Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) |
|
||||
| Guild payment | Rate, transport permission and income are distinct; power owner, shipper, contributors and independent rate-card beneficiary remain distinct. | [Rate/cancellation sources](GUILD_RATE_KARAMA_RULES.md), [implemented payment routing](GUILD_SHIPMENT_PAYMENTS.md) |
|
||||
| Deferred shipment arrival | Quote unsupported ordinary arrivals before saving Guild interception. A validated legacy ordinary declaration may return unspent on the typed arrival error; corrupted declarations and special parent/card receipts are not silently cleared. This repairs continuation without deciding simultaneous reaction priority. | [Reproduction and bounded recovery](DEFERRED_SHIPMENT_ARRIVAL.md) |
|
||||
| Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Structured Basic and base Advanced active-opportunity shipment promises bind within their documented scope (Advanced excludes Guild and optional modules); prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) |
|
||||
| Truthtrance hand categories | Count current physical hand custody by explicitly named primary role; alternate battle slots and faction powers do not reclassify cards. Only an aggregate answer is disclosed. | [Hand inventory](TRUTHTRANCE_HAND_INVENTORY.md) |
|
||||
| Moritani Advanced assassination preview | After an eligible battle loss, a different opposing-faction Traitor Card may be revealed even if its leader is already dead (zero bounty). Face-up set-aside and one private replacement occur during Mentat; one use per faction. Normal-call duration, uniform hidden-eligibility UX and exceptional combinations remain explicit. | [Runtime/source contract](MORITANI_ASSASSINATE_LEADERS.md) |
|
||||
| Fremen/Ecaz final-turn victory | Reciprocal allied Ecaz and Fremen with fighters in Sietch Tabr do not block the Fremen special victory. Ecaz alone/non-allied or a third faction still blocks. The Habbanya extension remains unsupported; ordinary winner/prediction and fallback order are preserved. | [Explicit FAQ and runtime](ECAZ_VICTORY_RULES.md#fremen-final-turn-runtime-follow-up) |
|
||||
| Truthtrance recorded knowledge | Questions compare an already recorded BG prediction, active submitted storm dial or actually known native Fremen forecast. Missing knowledge is unknown; only the aggregate answer is published. No future promise or unknown deck peek is created. | [Connected predicates and evidence](TRUTHTRANCE_KNOWLEDGE.md) |
|
||||
| Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) |
|
||||
| Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) |
|
||||
|
||||
+124
-2
@@ -168,6 +168,7 @@ import {
|
||||
VictoryQuoteError,
|
||||
type VictoryQuote,
|
||||
} from './victory-quote';
|
||||
import { fremenSpecialVictory, type FremenVictoryProgress } from './fremen-victory';
|
||||
import {
|
||||
createSpiceAllocation,
|
||||
quoteSpiceAllocation,
|
||||
@@ -9405,6 +9406,95 @@ function validateMovementArrival(g: Game, move: MovementOrder, cancelingChoam =
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function quoteShipmentArrival(
|
||||
g: Game,
|
||||
shipment: PendingShipment,
|
||||
ignoreGuildDecision = false,
|
||||
) {
|
||||
// Guild Ambassador arrivals have a staged continuation which deliberately
|
||||
// owns pendingAmbassador throughout shipment, Terror and accompaniment.
|
||||
// Its source-specific validator handles those gates in their settled order.
|
||||
if (shipment.source === 'ambassador') return;
|
||||
const p = getPlayer(g, shipment.player);
|
||||
const amount = shipment.alliedNoField
|
||||
? Math.max(1, shipment.amount)
|
||||
: shipment.amount + (shipment.smugglerCompanion ? 1 : 0);
|
||||
const guild = byFaction(g, 'guild');
|
||||
const guildPayment = guildShipmentIncome({
|
||||
guild: guild?.id,
|
||||
shipper: p.id,
|
||||
ally: p.ally,
|
||||
cost: shipment.cost,
|
||||
allyPayment: shipment.allyPayment,
|
||||
bankOnly: g.karamaShipping?.player === p.id,
|
||||
});
|
||||
const bg = byFaction(g, 'beneGesserit');
|
||||
const advisorFollowup = !!(
|
||||
p.faction !== 'fremen' &&
|
||||
bg &&
|
||||
bg.id !== p.id &&
|
||||
spiritualAdvisorMaximum(g, bg.id) > 0
|
||||
);
|
||||
const currentDecision = !!(
|
||||
g.decision &&
|
||||
!(
|
||||
ignoreGuildDecision &&
|
||||
g.decision.kind === 'guildShipment' &&
|
||||
g.decision.player === guild?.id &&
|
||||
g.decision.shipper === shipment.player &&
|
||||
g.decision.territory === shipment.territory &&
|
||||
g.decision.sector === shipment.sector
|
||||
)
|
||||
);
|
||||
return quoteCompletedMovementArrival({
|
||||
advanced: g.advanced,
|
||||
players: g.players,
|
||||
order: {
|
||||
player: p.id,
|
||||
origin: 'reserves',
|
||||
to: shipment.territory,
|
||||
advisors: shipment.advisors,
|
||||
wantsFighters: false,
|
||||
},
|
||||
ambassadors: g.ecazAmbassadors?.tokens ?? [],
|
||||
terror: homeworldTerrorEntryBlock(g, amount)
|
||||
? []
|
||||
: (g.moritaniTerror?.tokens ?? []),
|
||||
controls: {
|
||||
response:
|
||||
!!g.response ||
|
||||
guildPayment > 0 ||
|
||||
(g.advanced && p.faction === 'fremen' && shipment.sector === g.storm),
|
||||
decision: currentDecision || advisorFollowup,
|
||||
pendingTerror: !!g.pendingTerrorEntry,
|
||||
pendingAmbassador: !!g.pendingAmbassador,
|
||||
paidBox: !!g.pendingNullentropy,
|
||||
},
|
||||
flight: null,
|
||||
});
|
||||
}
|
||||
function validateShipmentArrival(g: Game, shipment: PendingShipment) {
|
||||
try {
|
||||
return quoteShipmentArrival(g, shipment);
|
||||
} catch (error) {
|
||||
if (error instanceof MovementArrivalError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function recoverableUnsupportedShipment(shipment: PendingShipment) {
|
||||
return (
|
||||
shipment.source === undefined &&
|
||||
shipment.ambassadorEvent === undefined &&
|
||||
shipment.noField === undefined &&
|
||||
shipment.noFieldSkillProof === undefined &&
|
||||
shipment.alliedNoField === undefined &&
|
||||
shipment.smuggler === undefined &&
|
||||
shipment.smugglerCompanion === undefined &&
|
||||
shipment.nexusEvent === undefined &&
|
||||
shipment.guildNexusEvent === undefined &&
|
||||
shipment.guildSecretEvent === undefined
|
||||
);
|
||||
}
|
||||
function completeMove(g: Game, move: MovementOrder) {
|
||||
if (move.noField) {
|
||||
const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, move.player));
|
||||
@@ -13152,16 +13242,24 @@ function victory(g: Game, quote: VictoryQuote = currentVictoryQuote(g)) {
|
||||
g.status = quote.status;
|
||||
if (g.winner.length) {
|
||||
g.status = 'finished';
|
||||
const occupy = strongholdProgress(g).progress.find(
|
||||
const progress = strongholdProgress(g).progress;
|
||||
const occupy = progress.find(
|
||||
(row) =>
|
||||
row.occupyTarget !== null &&
|
||||
row.jointlyOccupied.length >= row.occupyTarget &&
|
||||
row.members.length === g.winner.length &&
|
||||
row.members.every((id) => g.winner.includes(id)),
|
||||
);
|
||||
const fremen = g.turn === 10 && !progress.some((row) => row.qualifies)
|
||||
? fremenSpecialVictory(g) : null;
|
||||
const fremenWon = fremen?.qualifies && fremen.members.length === g.winner.length &&
|
||||
fremen.members.every((id) => g.winner.includes(id));
|
||||
const specialReason = fremenWon
|
||||
? ` Fremen special victory: both protected sietches meet the occupation condition and no prohibited faction occupies Tuek’s Sietch.${fremen.sietches.some((site) => site.ecazCooccupation) ? ' Allied Ecaz and Fremen may co-occupy Sietch Tabr without blocking this victory.' : ''}`
|
||||
: '';
|
||||
log(
|
||||
g,
|
||||
`${g.winner.map((id) => faction(getPlayer(g, id).faction).name).join(' and ')} won the game.${occupy ? ` Ecaz Occupy: both allies occupy ${occupy.jointlyOccupied.map((id) => territory(id).name).join(', ')} without opposing fighters, meeting the three-stronghold alliance target.` : ''}`,
|
||||
`${g.winner.map((id) => faction(getPlayer(g, id).faction).name).join(' and ')} won the game.${occupy ? ` Ecaz Occupy: both allies occupy ${occupy.jointlyOccupied.map((id) => territory(id).name).join(', ')} without opposing fighters, meeting the three-stronghold alliance target.` : ''}${specialReason}`,
|
||||
);
|
||||
} else if (g.status === 'finished')
|
||||
log(g, 'The tenth turn ended without a winner.');
|
||||
@@ -18179,6 +18277,7 @@ function validateGuildShipmentDecision(
|
||||
}
|
||||
function offerShipment(g: Game, shipment: PendingShipment) {
|
||||
validatePhysicalShipment(g, shipment);
|
||||
validateShipmentArrival(g, shipment);
|
||||
checkShipmentIncomeRounding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
|
||||
const p = getPlayer(g, shipment.player),
|
||||
id = p.id;
|
||||
@@ -18216,6 +18315,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
|
||||
return;
|
||||
}
|
||||
validatePhysicalShipment(g, shipment);
|
||||
validateShipmentArrival(g, shipment);
|
||||
if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'reserve',shipment);
|
||||
finishNexusRicheseShipment(g,shipment,'shipped');
|
||||
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'reserve',shipment);
|
||||
@@ -21287,6 +21387,22 @@ function applyActionInner(
|
||||
'Allow the shipment, or play the Guild special Karama.',
|
||||
);
|
||||
const shipment = validateGuildShipmentDecision(g, decision);
|
||||
try {
|
||||
quoteShipmentArrival(g, shipment, true);
|
||||
} catch (error) {
|
||||
if (!(error instanceof MovementArrivalError)) throw error;
|
||||
requireRule(
|
||||
recoverableUnsupportedShipment(shipment),
|
||||
error.message,
|
||||
);
|
||||
g.pendingShipment = null;
|
||||
g.decision = null;
|
||||
log(
|
||||
g,
|
||||
`${getPlayer(g, shipment.player).name}'s uncommitted shipment returned for another choice. ${error.message} No forces, spice, cards or shipment allowance were spent.`,
|
||||
);
|
||||
return g;
|
||||
}
|
||||
g.pendingShipment = null;
|
||||
commitShipment(g, shipment);
|
||||
} else if (decision.kind === 'nullentropy') {
|
||||
@@ -22773,6 +22889,9 @@ function applyActionInner(
|
||||
if (nexusEvent) recordNexusRicheseShipment(g,shipment);
|
||||
bindGuildCunningShipment(g,p,'reserve',shipment);
|
||||
if (shipment.noField) {
|
||||
// This cancellation window bypasses offerShipment, so reject known
|
||||
// unsupported arrival combinations before saving its private intent.
|
||||
validateShipmentArrival(g, shipment);
|
||||
checkShipmentIncomeRounding(g, p, cost, allyPayment);
|
||||
g.pendingShipment = shipment;
|
||||
g.response = { kind: 'richeseNoField', owner: p.id, passed: [],
|
||||
@@ -23621,9 +23740,11 @@ export function viewGame(state: Game, id: string) {
|
||||
normalizeBattle(g);
|
||||
const me = getPlayer(g, id);
|
||||
let victoryProgress: StrongholdProgress[] = [];
|
||||
let fremenVictory: FremenVictoryProgress | null = null;
|
||||
if (g.status === 'playing' || g.status === 'finished') {
|
||||
try {
|
||||
victoryProgress = strongholdProgress(state).progress;
|
||||
fremenVictory = fremenSpecialVictory(state);
|
||||
} catch (error) {
|
||||
// A malformed restored board has no reliable preview. Authoritative
|
||||
// victory still rejects it through its strict quote.
|
||||
@@ -23993,6 +24114,7 @@ export function viewGame(state: Game, id: string) {
|
||||
: null,
|
||||
winner: g.winner,
|
||||
victoryProgress,
|
||||
fremenVictory,
|
||||
shieldWallDestroyed: g.shieldWallDestroyed,
|
||||
stormDialers: g.stormDialers,
|
||||
stormSubmitted: Object.keys(g.stormDials),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { fighterCount } from './advisors';
|
||||
import { settledBoard, type BoardContext } from './board-resolution-quote';
|
||||
import { ecazOccupancyIdentity } from './ecaz-occupy';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { VictoryProgressError } from './victory-progress';
|
||||
|
||||
const PROTECTED_SIETCHES = ['sietch_tabr', 'habbanya_ridge_sietch'] as const;
|
||||
|
||||
export type FremenVictoryProgress = {
|
||||
player: string;
|
||||
members: string[];
|
||||
qualifies: boolean;
|
||||
sietches: {
|
||||
territory: string;
|
||||
blockers: string[];
|
||||
ecazCooccupation: boolean;
|
||||
}[];
|
||||
tueksBlockers: string[];
|
||||
};
|
||||
|
||||
/** Public current-board facts for the Fremen final-turn victory condition.
|
||||
* Turn timing, prior stronghold winners and the Guild fallback belong to the
|
||||
* victory quote. The only Ecaz exception established here is co-occupied
|
||||
* Sietch Tabr; the same forces still block Habbanya Ridge Sietch. */
|
||||
export function fremenSpecialVictory(
|
||||
g: BoardContext,
|
||||
): FremenVictoryProgress | null {
|
||||
try {
|
||||
const { players } = settledBoard(g);
|
||||
if (!players.length)
|
||||
throw new VictoryProgressError(
|
||||
'Fremen victory progress requires seated players.',
|
||||
);
|
||||
|
||||
// Reuse the public occupancy validator for faction uniqueness and exact
|
||||
// reciprocal alliances without reading any private player state.
|
||||
ecazOccupancyIdentity(players, players[0].id, {
|
||||
kind: 'territory',
|
||||
id: 'polar_sink',
|
||||
});
|
||||
|
||||
const fremen = players.find((player) => player.faction === 'fremen');
|
||||
if (!fremen) return null;
|
||||
const ally = players.find((player) => player.id === fremen.ally);
|
||||
const ecaz = ally?.faction === 'ecaz' ? ally : null;
|
||||
const members = g.order.filter(
|
||||
(player) => player === fremen.id || player === ally?.id,
|
||||
);
|
||||
|
||||
const sietches = PROTECTED_SIETCHES.map((territory) => {
|
||||
const ecazCooccupation = !!(
|
||||
ecaz &&
|
||||
fighterCount(fremen, territory) > 0 &&
|
||||
fighterCount(ecaz, territory) > 0
|
||||
);
|
||||
const blockers = g.order.filter((id) => {
|
||||
const player = players.find((candidate) => candidate.id === id)!;
|
||||
if (player.id === fremen.id || fighterCount(player, territory) === 0)
|
||||
return false;
|
||||
return !(
|
||||
territory === 'sietch_tabr' &&
|
||||
ecazCooccupation &&
|
||||
player.id === ecaz!.id
|
||||
);
|
||||
});
|
||||
return { territory, blockers, ecazCooccupation };
|
||||
});
|
||||
|
||||
const prohibited = new Set([
|
||||
'atreides',
|
||||
'harkonnen',
|
||||
'emperor',
|
||||
...(g.advanced ? ['richese'] : []),
|
||||
]);
|
||||
const tueksBlockers = g.order.filter((id) => {
|
||||
const player = players.find((candidate) => candidate.id === id)!;
|
||||
return (
|
||||
prohibited.has(player.faction) && presenceAt(player, 'tueks_sietch') > 0
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
player: fremen.id,
|
||||
members,
|
||||
qualifies:
|
||||
sietches.every((sietch) => sietch.blockers.length === 0) &&
|
||||
tueksBlockers.length === 0,
|
||||
sietches,
|
||||
tueksBlockers,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof VictoryProgressError) throw error;
|
||||
throw new VictoryProgressError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Invalid public Fremen victory state.',
|
||||
);
|
||||
}
|
||||
}
|
||||
+26
-3
@@ -143,7 +143,7 @@ const phaseDetails = [
|
||||
'A Fremen move using the two-territory advantage has a Karama response. Cancellation leaves the forces in place and limits this move to one territory; city ornithopters and a later extra move keep their normal rules.',
|
||||
'Guild transport can move a group across the planet or return it to reserves. Ordinary movement is checked against range, storm and stronghold occupancy.',
|
||||
'You may pass through an ally’s territory on a legal route that ends elsewhere. The printed clarification also permits shipping into allied territory followed by departure; that temporary arrival and its departure obligation are not yet supported by the table.',
|
||||
'Ecaz and its reciprocal ally may share territories permanently and count as one faction toward stronghold capacity. This applies to normal shipment, ground routes and Ambassador arrivals. It does not grant extra movement, bypass storm or mobile-stronghold entry rules, or apply to homeworlds. Their three jointly occupied stronghold victory is supported; shared desert allocation is also supported. Combined combat and the Fremen endgame exception remain unfinished.',
|
||||
'Ecaz and its reciprocal ally may share territories permanently and count as one faction toward stronghold capacity. This applies to normal shipment, ground routes and Ambassador arrivals. It does not grant extra movement, bypass storm or mobile-stronghold entry rules, or apply to homeworlds. Their three jointly occupied stronghold victory and shared desert allocation are supported. Allied Ecaz and Fremen together in Sietch Tabr do not block the Fremen final-turn victory; its other conditions still apply. Combined combat and the Habbanya extension remain unfinished.',
|
||||
'A restored pending physical shipment is checked again before forces or spice are committed. If its saved turn, force allocation, destination, price or authorized contribution no longer matches the table, it is rejected without changing the saved declaration.',
|
||||
'Finish shipment and movement explicitly. Any Bene Gesserit free-shipment decision and its response must finish first.',
|
||||
'Advanced Guild may choose when to take its complete shipment-and-movement turn. The table offers a choice before another player starts. Guild cannot interrupt a turn already underway.',
|
||||
@@ -2645,7 +2645,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'On turn ten, a normal stronghold victory comes first. Otherwise, check Fremen’s special condition, then award the game to Guild and its ally if present. If Guild is absent, Fremen and its ally win even when the usual Fremen special condition fails.',
|
||||
'When neither Guild nor Fremen is playing and nobody has reached the normal target, compare individual qualifying stronghold counts. Every player tied for the highest count wins. A correct Bene Gesserit prediction can replace this stronghold win.',
|
||||
'With tech tokens enabled, owning all three adds one stronghold. One player must hold all three, even in an alliance. If several sides reach their target at the same check, they share a normal win before any correct prediction replaces it.',
|
||||
'The table’s Stronghold victory progress shows current qualifying territories, a complete Tech Token set and Ecaz joint occupation. Tech Tokens cannot supply a jointly occupied territory. These public counts do not resolve a private prediction or award victory before the proper checkpoint.',
|
||||
'The table’s Stronghold victory progress shows current qualifying territories, a complete Tech Token set, Ecaz joint occupation and Fremen’s final-turn conditions. Allied Ecaz and Fremen may co-occupy Sietch Tabr without blocking that special win; solitary or non-allied Ecaz does not receive this exception. Tech Tokens cannot supply a jointly occupied territory. These public counts do not resolve a private prediction or award victory before the proper checkpoint.',
|
||||
'Other expansion additions to victory remain in development. Advanced and expansion faction starts remain disabled.',
|
||||
],
|
||||
example:
|
||||
@@ -2658,6 +2658,28 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'faction-beneGesserit',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fremen-ecaz-endgame',
|
||||
title: 'Fremen and Ecaz: the final-turn exception',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Integrated',
|
||||
summary: 'Allied Ecaz and Fremen may share Sietch Tabr while qualifying for the Fremen special victory.',
|
||||
steps: [
|
||||
'At the final-turn victory check, ordinary stronghold victories come first. If none qualifies, Fremen’s special occupation conditions are checked before the Guild fallback. A qualifying Fremen victory includes its ally and is not replaced by a Bene Gesserit prediction.',
|
||||
'Ecaz and Fremen must be current reciprocal allies and both have fighters in Sietch Tabr. Their co-occupation does not block the Fremen special condition. Ecaz alone, non-allied Ecaz or another faction’s fighters still block it. Accompanying Bene Gesserit advisors do not count as fighters.',
|
||||
'Habbanya Sietch retains its existing empty-or-Fremen condition; extending the Ecaz exception there is not yet supported. Tuek’s Sietch must contain no Atreides, Harkonnen or Emperor presence, and Advanced play also excludes Richese presence. A concealed No-Field counts without revealing its hidden value.',
|
||||
'The public victory guide lists the three territories and any blocking factions. It uses the same settled-board facts as automatic victory resolution. No extra confirmation is required; the table chronicle explains a successful special victory.',
|
||||
],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'The source-confirmed Sietch Tabr exception is connected in Basic and Advanced. Habbanya symmetry and broader expansion acceptance remain incomplete.', evidence: ['game/fremen-victory.ts', 'game/victory-quote.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'Read-only public territory conditions and blockers; victory resolves at the existing checkpoint without a new decision.', evidence: ['components/victory-progress.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles reach the automatic outcome through legal final-turn actions. Strategic defense of this allied exception is not separately calibrated.', evidence: ['tests/fremen-ecaz-victory-engine.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'The explicit FAQ exception is separated from the unsupported Habbanya extension.', evidence: ['docs/ECAZ_VICTORY_RULES.md', 'game/reference.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Public presence, precedence, all-profile continuation, JSON and authenticated SQLite recovery are covered. Final-turn positions are explicitly staged after genuine setup.', evidence: ['tests/fremen-victory.test.ts', 'tests/fremen-victory-controls.test.tsx', 'tests/fremen-ecaz-victory-engine.test.ts', 'tests/fremen-ecaz-victory-recovery.test.ts'] },
|
||||
],
|
||||
related: ['victory', 'faction-fremen', 'faction-ecaz'],
|
||||
},
|
||||
{
|
||||
id: 'stronghold-cards',
|
||||
title: 'Stronghold Cards: control and battle advantages',
|
||||
@@ -3661,6 +3683,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: Recruits, Reinforcements and Harass & Withdraw form a separate unfinished optional variant.',
|
||||
'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.',
|
||||
'Fresh unsupported movement arrivals reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. Simultaneous Ambassador/Terror order remains unresolved.',
|
||||
'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent to its shipper when allowance encounters that arrival guard, so the shipper can choose another destination. Older No-Field, Smuggler, Nexus and Ambassador shipments are excluded from this recovery. Simultaneous arrival order remains unfinished.',
|
||||
'Remaining abilities and combined timing can still encounter explicit development guards. Setup and saved-continuation evidence does not certify complete expansion play.',
|
||||
],
|
||||
related: ['setup', 'ix-modules', 'choam-modules', 'ecaz-modules', 'implementation-checklist'],
|
||||
@@ -3669,7 +3692,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Existing faction choices and private hands are reused. Ecaz has an owned sector-allocation control and setup waiting messages; Ixians have an explicit per-lot decline control. Public starts and full acceptance remain gated.', evidence: ['components/ecaz-setup.tsx', 'components/ix-richese-technology.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles use genuine setup choices and the shared Ecaz allocation quote, and can decline this Richese lot without using Technology. Combined gameplay strategy, guarded interactions and full-game reliability remain unfinished.', evidence: ['game/bots.ts', 'game/ecaz-setup.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'The faction profile records ordinary deck composition, independent variants, Ecaz territory allocation and deterministic setup-order interpretations, with unfinished play explicit.', evidence: ['docs/EXPANSION_FACTIONS_PROTOTYPE.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Setup, inventory, private views, JSON/SQLite continuation, concurrent start and dirty-lobby rejection have focused checks. Ixian per-lot decline has ownership, stale/corrupt decision, private card custody and concurrent recovery checks. Sample gameplay and browser evidence are recorded separately, with remaining guards retained. Deferred movement checks cover preflight, reserved cards and saved concurrent recovery.', evidence: ['tests/deferred-movement-arrival.test.ts', 'tests/deferred-movement-arrival-recovery.test.ts', 'tools/faction-games.ts', 'tests/expansion-factions-prototype.test.ts', 'tests/expansion-factions-recovery.test.ts', 'tests/ecaz-setup-controls.test.tsx', 'tests/ecaz-cards.test.ts', 'tests/ix-richese-technology.test.ts', 'tests/ix-richese-technology-controls.test.tsx', 'tests/ix-richese-technology-recovery.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Setup, inventory, private views, JSON/SQLite continuation, concurrent start and dirty-lobby rejection have focused checks. Ixian per-lot decline has ownership, stale/corrupt decision, private card custody and concurrent recovery checks. Sample gameplay and browser evidence are recorded separately, with remaining guards retained. Deferred movement and ordinary shipment checks cover arrival preflight, retained resources and saved concurrent recovery; special shipment receipts keep their separate guards.', evidence: ['tests/deferred-movement-arrival.test.ts', 'tests/deferred-movement-arrival-recovery.test.ts', 'tests/deferred-shipment-arrival.test.ts', 'tests/deferred-shipment-arrival-recovery.test.ts', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'tools/faction-games.ts', 'tests/expansion-factions-prototype.test.ts', 'tests/expansion-factions-recovery.test.ts', 'tests/ecaz-setup-controls.test.tsx', 'tests/ecaz-cards.test.ts', 'tests/ix-richese-technology.test.ts', 'tests/ix-richese-technology-controls.test.tsx', 'tests/ix-richese-technology-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
|
||||
+2
-20
@@ -2,7 +2,7 @@ import type { Game, Player } from './engine';
|
||||
import { FACTIONS } from './catalog';
|
||||
import { gameTerritories, validLocation } from './board';
|
||||
import { fighterCount } from './advisors';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { fremenSpecialVictory } from './fremen-victory';
|
||||
import {
|
||||
settledBoard,
|
||||
BoardResolutionError,
|
||||
@@ -165,25 +165,7 @@ export function quoteVictory(g: VictoryContext): VictoryQuote {
|
||||
if (!winner.length && g.turn === 10) {
|
||||
const fremen = players.find((p) => p.faction === 'fremen');
|
||||
const guild = players.find((p) => p.faction === 'guild');
|
||||
if (
|
||||
fremen &&
|
||||
players.every(
|
||||
(p) =>
|
||||
p.id === fremen.id ||
|
||||
(!fighterCount(p, 'sietch_tabr') &&
|
||||
!fighterCount(p, 'habbanya_ridge_sietch')),
|
||||
) &&
|
||||
players
|
||||
.filter((p) =>
|
||||
[
|
||||
'atreides',
|
||||
'harkonnen',
|
||||
'emperor',
|
||||
...(g.advanced ? ['richese'] : []),
|
||||
].includes(p.faction),
|
||||
)
|
||||
.every((p) => !presenceAt(p, 'tueks_sietch'))
|
||||
)
|
||||
if (fremen && fremenSpecialVictory(g)?.qualifies)
|
||||
winner = fremen.ally ? [fremen.id, fremen.ally] : [fremen.id];
|
||||
else if (guild) winner = guild.ally ? [guild.id, guild.ally] : [guild.id];
|
||||
else if (fremen)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import type { RoomsClock, SeatAuth } from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { legacyGuildArrival } from './fixture-deferred-shipment-arrival';
|
||||
|
||||
const clock: RoomsClock = { now: () => 49000, sleep: async () => {} };
|
||||
const allow = { type: 'decision', allow: true };
|
||||
const hash = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
const rows = (db: DatabaseSync) => ({
|
||||
rooms: db.prepare('SELECT * FROM rooms ORDER BY code').all(),
|
||||
seats: db.prepare('SELECT * FROM seats ORDER BY player_id').all(),
|
||||
});
|
||||
const custody = (g: Game) => ({
|
||||
players: g.players,
|
||||
deck: g.deck,
|
||||
discard: g.discard,
|
||||
richeseCache: g.richeseCache,
|
||||
spice: g.spice,
|
||||
traitorReserve: g.traitorReserve,
|
||||
ambassadors: g.ecazAmbassadors,
|
||||
terror: g.moritaniTerror,
|
||||
nexus: g.nexusCards,
|
||||
});
|
||||
|
||||
async function fixture(t: test.TestContext) {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const pending = legacyGuildArrival();
|
||||
for (const player of pending.players) delete player.bot;
|
||||
pending.version = 31;
|
||||
const code = pending.code;
|
||||
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
|
||||
.run(code, JSON.stringify(pending), pending.version, 48000);
|
||||
const tokens: Record<string, string> = {};
|
||||
const auths: Record<string, SeatAuth> = {};
|
||||
for (const player of pending.players) {
|
||||
tokens[player.id] = hash('deferred-shipment:' + player.id);
|
||||
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)')
|
||||
.run(hash(tokens[player.id]), code, player.id);
|
||||
auths[player.id] = await store.rooms.authenticate(code, tokens[player.id]);
|
||||
}
|
||||
const guild = pending.players.find((p) => p.faction === 'guild')!.id;
|
||||
return { ...store, pending, code, tokens, auths, guild };
|
||||
}
|
||||
|
||||
async function restoredViews(f: Awaited<ReturnType<typeof fixture>>, game: Game) {
|
||||
const before = rows(f.sqlite);
|
||||
for (const [id, token] of Object.entries(f.tokens)) {
|
||||
const rooms = f.restart();
|
||||
const auth = await rooms.authenticate(f.code, token);
|
||||
assert.equal(auth.playerId, id);
|
||||
const view = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(view, viewGame(game, id));
|
||||
assert.equal('pendingShipment' in view, false);
|
||||
for (const other of view.players.filter((p) => p.id !== id))
|
||||
for (const field of ['hand', 'traitors', 'spice', 'prediction'])
|
||||
assert.equal(field in other, false);
|
||||
}
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
}
|
||||
|
||||
void test('authenticated legacy Guild allowance returns an unsupported arrival once and keeps the private seat and shipment available after restart', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const original = rows(f.sqlite), resources = structuredClone(custody(f.pending));
|
||||
await restoredViews(f, f.pending);
|
||||
const other = f.pending.players.find((p) => p.id !== f.guild)!.id;
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[other], 31, allow, clock));
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[f.guild], 30, allow, clock));
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[f.guild], 31, { type: 'decision', allow: false }, clock));
|
||||
assert.deepEqual(rows(f.sqlite), original);
|
||||
assert.equal(f.writes.length, 0);
|
||||
|
||||
await f.restart().act(f.code, f.auths[f.guild], 31, allow, clock);
|
||||
const returned = await f.restart().readRoom(f.code);
|
||||
assert.equal(returned.version, 32);
|
||||
assert.equal(returned.decision, null);
|
||||
assert.equal(returned.pendingShipment, null);
|
||||
assert.equal(returned.active, f.pending.active);
|
||||
assert.deepEqual(custody(returned), resources);
|
||||
assert.match(returned.log.at(-1)!.text, /uncommitted shipment.*returned/i);
|
||||
assert.equal(f.writes.length, 1);
|
||||
await restoredViews(f, returned);
|
||||
const saved = rows(f.sqlite);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[f.guild], 31, allow, clock));
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[f.guild], 32, allow, clock));
|
||||
assert.deepEqual(rows(f.sqlite), saved);
|
||||
|
||||
await f.restart().act(f.code, f.auths[f.pending.active!], 32,
|
||||
{ type: 'ship', territory: 'polar_sink', sector: 0, amount: 1 }, clock);
|
||||
const alternative = await f.restart().readRoom(f.code);
|
||||
assert.equal(alternative.decision?.kind, 'guildShipment');
|
||||
assert.equal(alternative.pendingShipment?.territory, 'polar_sink');
|
||||
await f.restart().act(f.code, f.auths[f.guild], alternative.version, allow, clock);
|
||||
const arrived = await f.restart().readRoom(f.code);
|
||||
assert.equal(arrived.pendingShipment, null);
|
||||
assert.equal(arrived.players.find((p) => p.id === f.pending.active)!.shipped, true);
|
||||
assert.equal(arrived.players.find((p) => p.id === f.pending.active)!.forces['polar_sink:0'], 1);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), original.seats);
|
||||
await restoredViews(f, arrived);
|
||||
});
|
||||
|
||||
void test('concurrent saved Guild allowances persist exactly one unspent return', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let entered = 0, release!: () => void;
|
||||
const both = new Promise<void>((resolve) => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => { if (++entered === 2) release(); await both; };
|
||||
const results = await Promise.allSettled([f.rooms, f.restart()].map((rooms) =>
|
||||
rooms.act(f.code, f.auths[f.guild], 31, allow, clock)));
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(f.writes.map((w) => w.changes).sort((a, b) => a - b), [0, 1]);
|
||||
const returned = await f.restart().readRoom(f.code);
|
||||
assert.equal(returned.version, 32);
|
||||
assert.equal(returned.decision, null);
|
||||
assert.equal(returned.pendingShipment, null);
|
||||
assert.deepEqual(custody(returned), custody(f.pending));
|
||||
assert.equal(returned.log.filter((entry) => /uncommitted shipment.*returned/i.test(entry.text)).length, 1);
|
||||
await restoredViews(f, returned);
|
||||
});
|
||||
|
||||
void test('saved declaration corruption is rejected without returning or rewriting the pending shipment', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const corrupt = structuredClone(f.pending);
|
||||
corrupt.pendingShipment!.amount++;
|
||||
f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(corrupt), f.code);
|
||||
const before = rows(f.sqlite);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[f.guild], 31, allow, clock), /does not match/);
|
||||
assert.deepEqual(rows(f.sqlite), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
addShipmentArrivalAmbassador,
|
||||
legacyGuildArrival,
|
||||
richeseNoFieldArrivalAction,
|
||||
shipmentArrivalAction,
|
||||
shipmentArrivalGame,
|
||||
shipmentArrivalPlayer,
|
||||
shipmentArrivalReload,
|
||||
shipmentArrivalResources,
|
||||
} from './fixture-deferred-shipment-arrival';
|
||||
|
||||
const overlap = /Ambassadors combined with another arrival reaction/;
|
||||
|
||||
function reject(g: Game, player: string, action: Action, pattern: RegExp) {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, player, action), pattern);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
void test('an ordinary Guild shipment rejects a known Ambassador and advisor overlap before declaration or payment', (t) => {
|
||||
const g = shipmentArrivalGame();
|
||||
addShipmentArrivalAmbassador(g);
|
||||
const resources = structuredClone(shipmentArrivalResources(g));
|
||||
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
||||
throw Error('Unsupported shipment allocated an event');
|
||||
});
|
||||
reject(g, 'guild', shipmentArrivalAction, overlap);
|
||||
assert.deepEqual(shipmentArrivalResources(g), resources);
|
||||
assert.equal(g.pendingShipment, null);
|
||||
assert.equal(g.decision, null);
|
||||
assert.equal(uuid.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
void test('an own Richese No-Field rejects the unsupported arrival before saving its private cancellation response', () => {
|
||||
const g = shipmentArrivalGame();
|
||||
g.active = 'richese';
|
||||
shipmentArrivalPlayer(g, 'richese').shipped = false;
|
||||
addShipmentArrivalAmbassador(g);
|
||||
const action = richeseNoFieldArrivalAction(g);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'richese', action), overlap);
|
||||
assert.deepEqual(g, before);
|
||||
assert.equal(g.pendingShipment, null);
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.decision, null);
|
||||
assert.equal(shipmentArrivalPlayer(g, 'richese').noField!.deployed, null);
|
||||
});
|
||||
|
||||
void test('a saved Guild allowance returns only the uncommitted ordinary shipment and permits a safe replacement', () => {
|
||||
const g = legacyGuildArrival();
|
||||
const before = structuredClone(g);
|
||||
const resources = structuredClone(shipmentArrivalResources(g));
|
||||
const returned = applyAction(shipmentArrivalReload(g), 'guild', {
|
||||
type: 'decision',
|
||||
allow: true,
|
||||
});
|
||||
assert.deepEqual(g, before);
|
||||
assert.deepEqual(shipmentArrivalResources(returned), resources);
|
||||
assert.equal(returned.pendingShipment, null);
|
||||
assert.equal(returned.decision, null);
|
||||
assert.equal(shipmentArrivalPlayer(returned, 'guild').shipped, false);
|
||||
assert.deepEqual(shipmentArrivalPlayer(returned, 'guild').forces, {});
|
||||
assert.match(returned.log.at(-1)!.text, overlap);
|
||||
assert.match(
|
||||
returned.log.at(-1)!.text,
|
||||
/No forces, spice, cards or shipment allowance were spent/,
|
||||
);
|
||||
|
||||
const retried = applyAction(returned, 'guild', {
|
||||
...shipmentArrivalAction,
|
||||
territory: 'polar_sink',
|
||||
sector: 0,
|
||||
});
|
||||
assert.equal(retried.decision?.kind, 'guildShipment');
|
||||
assert.equal(retried.pendingShipment?.territory, 'polar_sink');
|
||||
});
|
||||
|
||||
void test('a supported Guild allowance still commits once and opens the advisor choice', () => {
|
||||
let g = shipmentArrivalGame();
|
||||
g = applyAction(g, 'guild', shipmentArrivalAction);
|
||||
assert.equal(g.decision?.kind, 'guildShipment');
|
||||
g = applyAction(shipmentArrivalReload(g), 'guild', {
|
||||
type: 'decision',
|
||||
allow: true,
|
||||
});
|
||||
assert.deepEqual(shipmentArrivalPlayer(g, 'guild').forces, {
|
||||
'carthag:11': 4,
|
||||
});
|
||||
assert.equal(shipmentArrivalPlayer(g, 'guild').reserves, 16);
|
||||
assert.equal(shipmentArrivalPlayer(g, 'guild').shipped, true);
|
||||
assert.equal(g.pendingShipment, null);
|
||||
assert.equal(g.decision?.kind, 'advisor');
|
||||
assert.equal(g.decision?.player, 'bg');
|
||||
});
|
||||
|
||||
void test('a malformed saved shipment remains rejected and is not treated as a recoverable arrival overlap', () => {
|
||||
const g = legacyGuildArrival();
|
||||
g.pendingShipment!.cost++;
|
||||
reject(
|
||||
g,
|
||||
'guild',
|
||||
{ type: 'decision', allow: true },
|
||||
/declared shipment price is no longer current/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('forged or null special receipts cannot enter the ordinary legacy return path', () => {
|
||||
for (const corrupt of [
|
||||
(g: Game) => {
|
||||
g.pendingShipment!.ambassadorEvent = 'forged';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingShipment!.guildSecretEvent = null as unknown as string;
|
||||
},
|
||||
]) {
|
||||
const g = legacyGuildArrival();
|
||||
corrupt(g);
|
||||
reject(g, 'guild', { type: 'decision', allow: true }, overlap);
|
||||
assert.ok(g.pendingShipment);
|
||||
assert.equal(g.decision?.kind, 'guildShipment');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeFactionExpansionsGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
|
||||
import {
|
||||
arrivalPlayer,
|
||||
arrivalReload,
|
||||
} from './deferred-movement-arrival-fixture';
|
||||
|
||||
export const shipmentArrivalAction: Action = {
|
||||
type: 'ship',
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
amount: 4,
|
||||
elite: 0,
|
||||
allyPayment: 0,
|
||||
};
|
||||
|
||||
export const shipmentArrivalReload = arrivalReload;
|
||||
export const shipmentArrivalPlayer = arrivalPlayer;
|
||||
|
||||
export function addShipmentArrivalAmbassador(g: Game) {
|
||||
const state = createAmbassadors(() => 0.2);
|
||||
const token = state.tokens.find((candidate) => candidate.effect === 'emperor');
|
||||
assert.ok(token);
|
||||
state.cohort = [
|
||||
token.id,
|
||||
...state.tokens
|
||||
.filter((candidate) => candidate.effect !== 'ecaz' && candidate.id !== token.id)
|
||||
.slice(0, 4)
|
||||
.map((candidate) => candidate.id),
|
||||
];
|
||||
for (const candidate of state.tokens) {
|
||||
candidate.zone =
|
||||
candidate.effect === 'ecaz' || state.cohort.includes(candidate.id)
|
||||
? 'supply'
|
||||
: 'pool';
|
||||
candidate.location = null;
|
||||
}
|
||||
g.ecazAmbassadors = placeAmbassador(state, token.id, {
|
||||
turn: g.turn,
|
||||
availableSpice: 20,
|
||||
destination: {
|
||||
id: 'carthag',
|
||||
stronghold: true,
|
||||
inStorm: false,
|
||||
allowed: true,
|
||||
},
|
||||
}).state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real expansion inventories reduced to the saved Advanced Guild arrival case.
|
||||
* `legacyGuildArrival` adds the second arrival reaction only after the ordinary
|
||||
* Guild decision has been persisted, matching the preflight-free save shape.
|
||||
*/
|
||||
export function shipmentArrivalGame(): Game {
|
||||
let g = createGame('SHIPARRY', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, [
|
||||
'ecaz',
|
||||
'choam',
|
||||
]);
|
||||
for (const [id, name, faction] of [
|
||||
['guild', 'Guild', 'guild'],
|
||||
['bg', 'Bene Gesserit', 'beneGesserit'],
|
||||
['moritani', 'Moritani', 'moritani'],
|
||||
['richese', 'Richese', 'richese'],
|
||||
] as const)
|
||||
joinGame(g, newPlayer(id, name, faction));
|
||||
for (const player of g.players) g = applyAction(g, player.id, { type: 'ready' });
|
||||
g = initializeFactionExpansionsGameForAudit(g);
|
||||
for (let step = 0; g.status === 'setup' && step < 30; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const player of g.players) {
|
||||
const view = viewGame(g, player.id);
|
||||
view.players.find((candidate) => candidate.id === view.me)!.bot = 'Medium';
|
||||
const action = botActions(view)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, player.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(next);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
Object.assign(g, {
|
||||
turn: 3,
|
||||
phase: 5,
|
||||
active: 'guild',
|
||||
storm: 18,
|
||||
ready: [],
|
||||
decision: null,
|
||||
response: null,
|
||||
phaseOpening: null,
|
||||
stormPending: null,
|
||||
});
|
||||
for (const player of g.players) {
|
||||
g.deck.push(...player.hand);
|
||||
player.hand = [];
|
||||
player.forces = {};
|
||||
player.reserves = 20;
|
||||
player.tanks = 0;
|
||||
player.spice = 20;
|
||||
player.shipped = player.id !== 'guild';
|
||||
player.moved = 0;
|
||||
player.bot = undefined;
|
||||
if (player.elites) {
|
||||
player.elites.forces = {};
|
||||
player.elites.reserves = 3;
|
||||
player.elites.tanks = 0;
|
||||
}
|
||||
}
|
||||
const bg = arrivalPlayer(g, 'bg');
|
||||
bg.advisors = {};
|
||||
const guild = arrivalPlayer(g, 'guild');
|
||||
g.active = guild.id;
|
||||
g.order = g.players.map((player) => player.id);
|
||||
g.movementRemaining = [...g.order];
|
||||
g.decision = null;
|
||||
g.response = null;
|
||||
g.pendingShipment = null;
|
||||
return g;
|
||||
}
|
||||
|
||||
export function legacyGuildArrival(): Game {
|
||||
let g = shipmentArrivalGame();
|
||||
g = applyAction(g, 'guild', shipmentArrivalAction);
|
||||
assert.equal(g.decision?.kind, 'guildShipment');
|
||||
assert.ok(g.pendingShipment);
|
||||
addShipmentArrivalAmbassador(g);
|
||||
return shipmentArrivalReload(g);
|
||||
}
|
||||
|
||||
export function richeseNoFieldArrivalAction(g: Game): Action {
|
||||
const richese = arrivalPlayer(g, 'richese');
|
||||
assert.ok(richese.noField);
|
||||
return {
|
||||
type: 'ship',
|
||||
noField: richese.noField.tokens[0].id,
|
||||
event: richese.noFieldEvent,
|
||||
territory: 'carthag',
|
||||
sector: 11,
|
||||
};
|
||||
}
|
||||
|
||||
export function shipmentArrivalResources(g: Game) {
|
||||
return {
|
||||
players: g.players,
|
||||
deck: g.deck,
|
||||
discard: g.discard,
|
||||
spice: g.spice,
|
||||
cache: g.richeseCache,
|
||||
ambassadors: g.ecazAmbassadors,
|
||||
terror: g.moritaniTerror,
|
||||
phase: g.phase,
|
||||
turn: g.turn,
|
||||
active: g.active,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeFactionExpansionsGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
|
||||
/** Genuine setup followed by a conserved, explicitly staged final Collection
|
||||
* position. This proves the endgame path, not naturally reached co-occupation. */
|
||||
export function fremenEcazFinalTurn(advanced = true, ids = ['ec', 'fr', 'gu', 'bg']) {
|
||||
let g = createGame('FREMENQA', newPlayer(ids[0], 'Ecaz', 'ecaz'), advanced, ['ecaz']);
|
||||
for (const [index, faction] of (['fremen', 'guild', 'beneGesserit'] as const).entries())
|
||||
joinGame(g, newPlayer(ids[index + 1], faction, faction));
|
||||
for (const p of g.players) p.ready = true;
|
||||
g = initializeFactionExpansionsGameForAudit(g);
|
||||
for (let i = 0; g.status === 'setup' && i < 80; i++) {
|
||||
const next = g.players.flatMap((p) => {
|
||||
const view = viewGame(g, p.id);
|
||||
view.players.find((other) => other.id === p.id)!.bot = 'Medium';
|
||||
return botActions(view).map((action) => ({ id: p.id, action }));
|
||||
})[0];
|
||||
assert.ok(next, 'Genuine setup must provide a legal action');
|
||||
g = applyAction(g, next.id, next.action);
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
for (const p of g.players) {
|
||||
p.reserves += Object.values(p.forces).reduce((a, b) => a + b, 0);
|
||||
p.forces = {};
|
||||
if (p.elites) {
|
||||
p.elites.reserves += Object.values(p.elites.forces).reduce((a, b) => a + b, 0);
|
||||
p.elites.forces = {};
|
||||
}
|
||||
if (p.advisors) p.advisors = {};
|
||||
}
|
||||
for (const p of g.players.slice(0, 2)) {
|
||||
p.forces['sietch_tabr:14'] = 3;
|
||||
p.reserves -= 3;
|
||||
p.ally = g.players.find((other) => other.id !== p.id && ['ecaz', 'fremen'].includes(other.faction))!.id;
|
||||
p.allySinceTurn = 9;
|
||||
}
|
||||
g.turn = 10; g.phase = 7; g.storm = 18; g.ready = []; g.active = null;
|
||||
g.order = g.players.map((p) => p.id); g.phaseOpening = null;
|
||||
g.players[3].prediction = { faction: 'fremen', turn: 10 };
|
||||
for (const p of g.players) viewGame(g, p.id);
|
||||
return g;
|
||||
}
|
||||
|
||||
export function finishFremenEcazTurn(state: Game, profile: Difficulty = 'Medium') {
|
||||
let g = JSON.parse(JSON.stringify(state)) as Game;
|
||||
for (let i = 0; g.status === 'playing' && i < 30; i++) {
|
||||
const next = g.players.flatMap((p) => {
|
||||
const view = viewGame(g, p.id);
|
||||
view.players.find((other) => other.id === p.id)!.bot = profile;
|
||||
return botActions(view).map((action) => ({ id: p.id, action }));
|
||||
})[0];
|
||||
assert.ok(next, 'Final-turn continuation must have a legal action');
|
||||
g = applyAction(JSON.parse(JSON.stringify(g)), next.id, next.action);
|
||||
}
|
||||
assert.equal(g.status, 'finished');
|
||||
return g;
|
||||
}
|
||||
|
||||
export function fremenEcazCustody(g: Game) {
|
||||
return {
|
||||
cards: [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)].map((c) => c.id).sort(),
|
||||
traitors: [...(g.traitorReserve ?? []), ...g.players.flatMap((p) => p.traitors)].sort(),
|
||||
forces: g.players.map((p) => ({ id: p.id, reserves: p.reserves, tanks: p.tanks, forces: p.forces, elites: p.elites })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { quoteVictory } from '../game/victory-quote';
|
||||
import { fremenEcazFinalTurn, finishFremenEcazTurn, fremenEcazCustody } from './fixture-fremen-ecaz-victory';
|
||||
|
||||
for (const advanced of [false, true])
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} final-turn co-occupation wins through real readiness without BG prediction or Guild stealing it`, () => {
|
||||
const g = fremenEcazFinalTurn(advanced), before = structuredClone(g);
|
||||
const done = finishFremenEcazTurn(g);
|
||||
assert.deepEqual(done.winner, ['fr', 'ec']);
|
||||
assert.deepEqual(g, before);
|
||||
assert.deepEqual(fremenEcazCustody(done), fremenEcazCustody(g));
|
||||
assert.match(done.log.at(-1)!.text, /Fremen special victory.*Allied Ecaz and Fremen/);
|
||||
for (const p of g.players) {
|
||||
const view = viewGame(done, p.id);
|
||||
assert.equal(view.fremenVictory?.qualifies, true);
|
||||
assert.deepEqual(viewGame(JSON.parse(JSON.stringify(done)), p.id), view);
|
||||
for (const other of view.players.filter((r) => r.id !== p.id))
|
||||
for (const field of ['spice', 'hand', 'traitors', 'prediction']) assert.equal(field in other, false);
|
||||
}
|
||||
assert.throws(() => applyAction(done, 'fr', { type: 'ready' }));
|
||||
});
|
||||
|
||||
void test('all four AI profiles complete the same settled qualifying board without changing cards or forces', () => {
|
||||
const g = fremenEcazFinalTurn();
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const done = finishFremenEcazTurn(g, profile);
|
||||
assert.deepEqual(done.winner, ['fr', 'ec']);
|
||||
assert.deepEqual(fremenEcazCustody(done), fremenEcazCustody(g));
|
||||
}
|
||||
});
|
||||
|
||||
void test('prospective guide does not award an early victory and a normal winner retains precedence', () => {
|
||||
const g = fremenEcazFinalTurn();
|
||||
g.turn = 9;
|
||||
assert.equal(viewGame(g, 'ec').fremenVictory?.qualifies, true);
|
||||
assert.deepEqual(quoteVictory(g).winner, []);
|
||||
g.turn = 10;
|
||||
const guild = g.players[2];
|
||||
guild.forces = { 'arrakeen:10': 1, 'carthag:11': 1, 'tueks_sietch:5': 1 };
|
||||
guild.reserves -= 3;
|
||||
assert.deepEqual(finishFremenEcazTurn(g).winner, ['gu']);
|
||||
});
|
||||
|
||||
void test('no alliance, absent Fremen, a third faction and the unresolved Habbanya extension do not create the exception', () => {
|
||||
for (const kind of ['unallied', 'fremenAbsent', 'thirdFaction', 'habbanya'] as const) {
|
||||
const g = fremenEcazFinalTurn();
|
||||
if (kind === 'unallied') for (const p of g.players.slice(0, 2)) p.ally = null;
|
||||
if (kind === 'fremenAbsent') { g.players[1].reserves += 3; g.players[1].forces = {}; }
|
||||
if (kind === 'thirdFaction') { g.players[2].reserves--; g.players[2].forces['sietch_tabr:14'] = 1; }
|
||||
if (kind === 'habbanya') for (const p of g.players.slice(0, 2)) p.forces = { 'habbanya_ridge_sietch:17': 3 };
|
||||
assert.equal(viewGame(g, 'ec').fremenVictory?.qualifies, false);
|
||||
const done = finishFremenEcazTurn(g);
|
||||
assert.deepEqual(done.winner, ['gu']);
|
||||
assert.doesNotMatch(done.log.at(-1)!.text, /Fremen special victory/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { fremenEcazFinalTurn, fremenEcazCustody } from './fixture-fremen-ecaz-victory';
|
||||
|
||||
const clock: RoomsClock = { now: () => 42000, sleep: async () => {} };
|
||||
const savedRows = (sqlite: DatabaseSync) => sqlite.prepare('SELECT state, version FROM rooms').all();
|
||||
async function fixture(t: test.TestContext) {
|
||||
const store = unitStore(); t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom('Ecaz victory QA', 'ecaz', false, ['ecaz']);
|
||||
const code = made.view.code, tokens = [made.token];
|
||||
for (const faction of ['fremen', 'guild', 'beneGesserit'] as const) tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token)));
|
||||
const g = fremenEcazFinalTurn(true, auths.map((a) => a.playerId));
|
||||
g.code = code; g.version = (await store.rooms.readRoom(code)).version;
|
||||
store.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?').run(JSON.stringify(g), g.version, code);
|
||||
for (const auth of auths.slice(0, -1)) {
|
||||
const current = await store.rooms.readRoom(code);
|
||||
await store.rooms.act(code, auth, current.version, { type: 'ready' }, clock);
|
||||
}
|
||||
const pending = await store.rooms.readRoom(code);
|
||||
assert.equal(pending.phase, 7); assert.equal(pending.status, 'playing');
|
||||
store.writes.length = 0;
|
||||
return { ...store, code, tokens, auths, pending };
|
||||
}
|
||||
|
||||
void test('restored final readiness awards the same allied win once and preserves private seats and physical custody', async (t) => {
|
||||
const f = await fixture(t), before = savedRows(f.sqlite);
|
||||
const seatRows = f.sqlite.prepare('SELECT * FROM seats').all();
|
||||
for (const [i, token] of f.tokens.entries()) {
|
||||
const rooms = f.restart(), auth = await rooms.authenticate(f.code, token);
|
||||
assert.deepEqual(await rooms.readSeatView(f.code, auth), viewGame(f.pending, f.auths[i].playerId));
|
||||
}
|
||||
assert.deepEqual(savedRows(f.sqlite), before);
|
||||
const rooms = f.restart(), auth = await rooms.authenticate(f.code, f.tokens[3]);
|
||||
await rooms.act(f.code, auth, f.pending.version, { type: 'ready' }, clock);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.status, 'finished');
|
||||
assert.deepEqual(done.winner, [f.auths[1].playerId, f.auths[0].playerId]);
|
||||
assert.deepEqual(fremenEcazCustody(done), fremenEcazCustody(f.pending));
|
||||
assert.equal(done.log.filter((entry) => entry.text.includes('Fremen special victory')).length, 1);
|
||||
const resolvedRows = savedRows(f.sqlite);
|
||||
for (const [i, token] of f.tokens.entries()) {
|
||||
const restored = f.restart(), seat = await restored.authenticate(f.code, token);
|
||||
const view = await restored.readSeatView(f.code, seat);
|
||||
assert.deepEqual(view, viewGame(done, f.auths[i].playerId));
|
||||
assert.deepEqual(viewGame(JSON.parse(JSON.stringify(done)) as Game, seat.playerId), view);
|
||||
for (const rival of view.players.filter((p) => p.id !== seat.playerId))
|
||||
for (const key of ['hand', 'traitors', 'spice', 'prediction']) assert.equal(key in rival, false);
|
||||
}
|
||||
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
await assert.rejects(f.restart().act(f.code, auth, f.pending.version, { type: 'ready' }, clock));
|
||||
await assert.rejects(f.restart().act(f.code, auth, done.version, { type: 'ready' }, clock));
|
||||
assert.deepEqual(savedRows(f.sqlite), resolvedRows);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seatRows);
|
||||
});
|
||||
|
||||
void test('competing final-readiness writes cannot duplicate victory settlement', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let entered = 0; let release!: () => void;
|
||||
const both = new Promise<void>((resolve) => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => { if (++entered === 2) release(); await both; };
|
||||
const results = await Promise.allSettled([0, 1].map(() => f.rooms.act(f.code, f.auths[3], f.pending.version, { type: 'ready' }, clock)));
|
||||
f.hooks.beforeWrite = undefined;
|
||||
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(f.writes.map((w) => w.changes).sort((a, b) => a - b), [0, 1]);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.version, f.pending.version + 1);
|
||||
assert.equal(done.log.filter((entry) => entry.text.includes('Fremen special victory')).length, 1);
|
||||
assert.deepEqual(fremenEcazCustody(done), fremenEcazCustody(f.pending));
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { VictoryProgress } from '../components/victory-progress';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { fremenEcazFinalTurn } from './fixture-fremen-ecaz-victory';
|
||||
|
||||
void test('public final-turn guide explains shared Tabr and keeps the victory checkpoint explicit', () => {
|
||||
const g = fremenEcazFinalTurn();
|
||||
const views = g.players.map((p) => viewGame(g, p.id));
|
||||
const html = views.map((v) => renderToStaticMarkup(<VictoryProgress progress={v.victoryProgress} players={v.players} fremen={v.fremenVictory} />));
|
||||
assert.ok(html.every((page) => page === html[0]));
|
||||
assert.match(html[0], /permitted allied Ecaz and Fremen co-occupation/);
|
||||
assert.match(html[0], /only at the final-turn check/);
|
||||
assert.match(html[0], /A Bene Gesserit prediction cannot replace/);
|
||||
assert.doesNotMatch(html[0], /<button/);
|
||||
});
|
||||
|
||||
void test('public guide identifies a blocking faction without presenting an ineligible board as a win', () => {
|
||||
const g = fremenEcazFinalTurn();
|
||||
g.players[2].forces = { 'habbanya_ridge_sietch:17': 1 }; g.players[2].reserves--;
|
||||
const v = viewGame(g, 'ec');
|
||||
const html = renderToStaticMarkup(<VictoryProgress progress={v.victoryProgress} players={v.players} fremen={v.fremenVictory} />);
|
||||
assert.match(html, /blocked by Spacing Guild/);
|
||||
assert.match(html, /special conditions are not met/);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { BoardContext } from '../game/board-resolution-quote';
|
||||
import { fremenSpecialVictory } from '../game/fremen-victory';
|
||||
import { VictoryProgressError } from '../game/victory-progress';
|
||||
|
||||
function fixture(advanced = false): BoardContext {
|
||||
return {
|
||||
advanced,
|
||||
storm: 18,
|
||||
order: ['x', 'f', 'e', 'r'],
|
||||
players: [
|
||||
{ id: 'f', faction: 'fremen', ally: 'e', forces: {} },
|
||||
{ id: 'e', faction: 'ecaz', ally: 'f', forces: {} },
|
||||
{ id: 'x', faction: 'beneGesserit', ally: null, forces: {} },
|
||||
{ id: 'r', faction: 'richese', ally: null, forces: {} },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function seat(g: BoardContext, id: string) {
|
||||
return g.players.find((player) => player.id === id)!;
|
||||
}
|
||||
|
||||
function place(g: BoardContext, id: string, key: string) {
|
||||
const player = seat(g, id);
|
||||
player.forces = { ...player.forces, [key]: 1 };
|
||||
}
|
||||
|
||||
void test('allied Ecaz co-occupation is ignored only at Sietch Tabr in Basic and Advanced', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const g = fixture(advanced);
|
||||
place(g, 'f', 'sietch_tabr:14');
|
||||
place(g, 'e', 'sietch_tabr:14');
|
||||
const result = fremenSpecialVictory(g)!;
|
||||
assert.equal(result.player, 'f');
|
||||
assert.deepEqual(result.members, ['f', 'e']);
|
||||
assert.equal(result.qualifies, true);
|
||||
assert.deepEqual(result.sietches, [
|
||||
{
|
||||
territory: 'sietch_tabr',
|
||||
blockers: [],
|
||||
ecazCooccupation: true,
|
||||
},
|
||||
{
|
||||
territory: 'habbanya_ridge_sietch',
|
||||
blockers: [],
|
||||
ecazCooccupation: false,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(result.tueksBlockers, []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a valid board without Fremen has no special-victory prospect', () => {
|
||||
const g = fixture();
|
||||
seat(g, 'f').faction = 'atreides';
|
||||
assert.equal(fremenSpecialVictory(g), null);
|
||||
});
|
||||
|
||||
void test('solitary, nonallied and third-party Ecaz presence do not receive the Tabr exception', () => {
|
||||
const solitary = fixture();
|
||||
place(solitary, 'e', 'sietch_tabr:14');
|
||||
assert.deepEqual(fremenSpecialVictory(solitary)!.sietches[0].blockers, ['e']);
|
||||
|
||||
const nonallied = fixture();
|
||||
seat(nonallied, 'f').ally = null;
|
||||
seat(nonallied, 'e').ally = null;
|
||||
place(nonallied, 'f', 'sietch_tabr:14');
|
||||
place(nonallied, 'e', 'sietch_tabr:14');
|
||||
assert.deepEqual(fremenSpecialVictory(nonallied)!.sietches[0].blockers, [
|
||||
'e',
|
||||
]);
|
||||
|
||||
const third = fixture();
|
||||
place(third, 'f', 'sietch_tabr:14');
|
||||
place(third, 'e', 'sietch_tabr:14');
|
||||
place(third, 'r', 'sietch_tabr:14');
|
||||
assert.deepEqual(fremenSpecialVictory(third)!.sietches[0].blockers, ['r']);
|
||||
assert.equal(fremenSpecialVictory(third)!.qualifies, false);
|
||||
});
|
||||
|
||||
void test('Ecaz co-occupation at Habbanya remains a blocker', () => {
|
||||
const g = fixture();
|
||||
place(g, 'f', 'habbanya_ridge_sietch:17');
|
||||
place(g, 'e', 'habbanya_ridge_sietch:17');
|
||||
const result = fremenSpecialVictory(g)!;
|
||||
assert.equal(result.sietches[1].ecazCooccupation, true);
|
||||
assert.deepEqual(result.sietches[1].blockers, ['e']);
|
||||
assert.equal(result.qualifies, false);
|
||||
});
|
||||
|
||||
void test('accompanied BG advisors do not block, while mandatory lone-advisor release restores fighter presence', () => {
|
||||
const accompanied = fixture(true);
|
||||
place(accompanied, 'f', 'habbanya_ridge_sietch:17');
|
||||
place(accompanied, 'x', 'habbanya_ridge_sietch:17');
|
||||
seat(accompanied, 'x').advisors = { habbanya_ridge_sietch: {} };
|
||||
assert.deepEqual(fremenSpecialVictory(accompanied)!.sietches[1].blockers, []);
|
||||
|
||||
const released = fixture(true);
|
||||
place(released, 'x', 'sietch_tabr:14');
|
||||
seat(released, 'x').advisors = { sietch_tabr: {} };
|
||||
const before = structuredClone(released);
|
||||
assert.deepEqual(fremenSpecialVictory(released)!.sietches[0].blockers, ['x']);
|
||||
assert.deepEqual(released, before);
|
||||
});
|
||||
|
||||
void test('Tuek blockers preserve Basic and Advanced faction scope and never inspect marker secrets', () => {
|
||||
const basic = fixture(false);
|
||||
place(basic, 'r', 'tueks_sietch:5');
|
||||
assert.deepEqual(fremenSpecialVictory(basic)!.tueksBlockers, []);
|
||||
|
||||
const advanced = fixture(true);
|
||||
seat(advanced, 'r').noField = {
|
||||
deployed: { location: { territory: 'tueks_sietch', sector: 5 } },
|
||||
};
|
||||
Object.defineProperty(seat(advanced, 'r').noField!.deployed, 'value', {
|
||||
get() {
|
||||
throw new Error('Hidden marker denomination read');
|
||||
},
|
||||
});
|
||||
for (const player of advanced.players)
|
||||
for (const key of ['hand', 'spice', 'leaders', 'prediction'])
|
||||
Object.defineProperty(player, key, {
|
||||
get() {
|
||||
throw new Error(`Private field read: ${key}`);
|
||||
},
|
||||
});
|
||||
const result = fremenSpecialVictory(advanced)!;
|
||||
assert.deepEqual(result.tueksBlockers, ['r']);
|
||||
assert.equal(result.qualifies, false);
|
||||
});
|
||||
|
||||
void test('members and blockers follow public order rather than player-array order', () => {
|
||||
const g = fixture();
|
||||
g.order = ['r', 'e', 'x', 'f'];
|
||||
place(g, 'f', 'sietch_tabr:14');
|
||||
place(g, 'e', 'sietch_tabr:14');
|
||||
place(g, 'r', 'habbanya_ridge_sietch:17');
|
||||
place(g, 'x', 'habbanya_ridge_sietch:17');
|
||||
assert.deepEqual(fremenSpecialVictory(g), {
|
||||
player: 'f',
|
||||
members: ['e', 'f'],
|
||||
qualifies: false,
|
||||
sietches: [
|
||||
{
|
||||
territory: 'sietch_tabr',
|
||||
blockers: [],
|
||||
ecazCooccupation: true,
|
||||
},
|
||||
{
|
||||
territory: 'habbanya_ridge_sietch',
|
||||
blockers: ['r', 'x'],
|
||||
ecazCooccupation: false,
|
||||
},
|
||||
],
|
||||
tueksBlockers: [],
|
||||
});
|
||||
});
|
||||
|
||||
void test('invalid shared board and alliance state is reported through VictoryProgressError', () => {
|
||||
const invalid = fixture();
|
||||
seat(invalid, 'e').ally = null;
|
||||
assert.throws(() => fremenSpecialVictory(invalid), VictoryProgressError);
|
||||
const malformed = fixture();
|
||||
seat(malformed, 'x').forces = { 'sietch_tabr:99': 1 };
|
||||
assert.throws(() => fremenSpecialVictory(malformed), VictoryProgressError);
|
||||
});
|
||||
Reference in new issue
Block a user